Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Optional regeneration step. If t27c is on $PATH, re-emit gen/rust/wire.rs
// from specs/wire.t27 so the tree stays honest about spec drift. If t27c is
// not available (CI / most contributor machines), skip silently — gen/rust/
// is committed and does not require t27c to build.
//
// Anchor: phi^2 + phi^-2 = 3.

use std::path::Path;
use std::process::Command;

fn main() {
let spec = Path::new("specs/wire.t27");
if !spec.exists() {
// Not a t27-flipped module tree — nothing to do.
return;
}
println!("cargo:rerun-if-changed=specs/wire.t27");
println!("cargo:rerun-if-env-changed=T27C");

// Off by default. Contributors who want spec/gen drift enforcement can set
// T27C_REGENERATE=1 and either put t27c on $PATH or point T27C at it.
if std::env::var_os("T27C_REGENERATE").is_none() {
return;
}

let t27c = std::env::var("T27C").unwrap_or_else(|_| "t27c".to_string());
let out = Command::new(&t27c)
.args(["gen-rust", "specs/wire.t27"])
.output();
let out = match out {
Ok(o) => o,
Err(e) => {
println!("cargo:warning=t27c invocation failed ({e}); skipping regen");
return;
}
};
if !out.status.success() {
println!(
"cargo:warning=t27c gen-rust returned {:?}; keeping committed gen/rust/wire.rs",
out.status
);
return;
}
// We do NOT overwrite gen/rust/wire.rs from build.rs, because t27c-0.1.0
// emits `return ();` on bit-shifts and would clobber the hand-written
// be_byte / u32_be stubs. Once the parser bug is fixed upstream, delete
// the stubs from gen/rust/wire.rs and enable direct overwrite here.
println!("cargo:warning=t27c ran cleanly; compare stdout against gen/rust/wire.rs manually");
}
106 changes: 106 additions & 0 deletions docs/T27_FIRST_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# T27-first migration — wire.rs (partial flip)

Anchor: `phi^2 + phi^-2 = 3`.

## Why

Prior state: `specs/wire.t27` and `src/wire.rs` were maintained in parallel. The spec was proven correct via `vvp` but was not the source of truth for the daemon — Rust was written by hand and drifted freely.

New state: `.t27` spec is SSOT. `t27c gen-rust` emits `gen/rust/wire.rs` deterministically. Hand-written Rust in `src/wire.rs` re-exports the generated symbols and only adds ergonomic wrappers (`Header` struct, `FrameKind` enum, `to_bytes` / `parse`) on top. Any drift between spec and daemon is now a build-time or test-time failure, not a review-time oversight.

## What flipped

Currently auto-generated (from `specs/wire.t27` via `t27c gen-rust`):

| Symbol | Kind |
|---|---|
| `VERSION` | `const u8 = 1` |
| `KIND_HELLO` | `const u8 = 0` |
| `KIND_DATA` | `const u8 = 1` |
| `HEADER_LEN` | `const usize = 11` |
| `frame_kind_valid(k: u8) -> bool` | pure predicate |
| `header_byte(kind, src, dst, ttl, idx) -> u8` | pure indexed layout |
| `parse_accepts(b0: u8, b1: u8) -> bool` | pure predicate |

Still hand-written in `src/wire.rs`:

- `Header` struct, `FrameKind` enum, `Header::to_bytes`, `Header::parse`.
- These now delegate all constants and layout decisions to the auto-gen module — they do not re-declare `VERSION`, `HEADER_LEN`, or the byte layout.

## Bootstrap limitation (why not full flip yet)

`t27c-0.1.0` bootstrap has a missing lowering for `ExprCast` (the `expr as Type` form) in the Rust, Zig, and C text emitters. Only `gen-verilog` implements the cast. On the other three backends the AST node falls through the default arm of `expr_to_rust` / `expr_to_zig` / the C printer and produces the unit value `"()"`, which then miscompiles: `return ();` in a `-> u8` Rust function is a type error, `return ;` in Zig is a parse error, and `gen-c` prints an honest `/* unsupported: ExprCast */`.

Initial triage in this session guessed the bug was in bit-shift parsing, because the first `wire.t27` line that failed was `((w >> 24) & 255) as u8`. Isolation showed the shift is a red herring — the cast is what breaks. Repro table and root-cause pointer live in the upstream issue: `gHashTag/t27#1314`.

Because `be_byte` and `u32_be` both need `as u8` / `as u32` casts, neither can come from the compiler in its current form. Workaround in this PR: `gen/rust/wire.rs` carries hand-written `be_byte` / `u32_be` stubs beneath a banner that documents the limitation. When `t27c` gains an `ExprCast` arm in the Rust emitter, delete the stubs and let `gen-rust` emit them.

Tracked upstream: [gHashTag/t27#1314](https://github.com/gHashTag/t27/issues/1314).

## Build story

- `gen/rust/wire.rs` is committed. Contributors do not need `t27c` installed to build tri-net.
- `build.rs` will optionally invoke `t27c gen-rust` when `T27C_REGENERATE=1` is set and `t27c` is on `$PATH` (or `T27C=/path/to/t27c`). It prints a warning if the invocation fails and does not fail the build — CI without `t27c` still passes.
- `build.rs` intentionally does not overwrite `gen/rust/wire.rs` today, because that would clobber the hand-written stubs. Once the `ExprCast` lowering lands in `t27c` (t27#1314, fixed by t27#1320), switch it to a direct overwrite.

## Test story

`src/wire.rs` gains two guardrail tests:

- `t27_gen_constants_match_hand_written` — pins auto-gen constant values.
- `t27_gen_predicates_match_semantics` — exercises `frame_kind_valid` and `parse_accepts` on representative inputs.

If someone edits `specs/wire.t27` and reruns `t27c gen-rust`, and the constants shift, these tests fail loudly. Existing `header_roundtrips` and `bad_version_rejected` continue to cover end-to-end serialization.

Green as of this commit:

- `cargo test --lib` — 101/0.
- `cargo test --test m2_routing_pure_logic` — 25/0.
- `cargo fmt --all -- --check` — clean.

## Next flips (out of scope for this PR)

- Full flip once the `ExprCast` lowering lands in `t27c` (t27#1314 → fixed by t27#1320) — `be_byte` / `u32_be` become auto-gen too.
- `src/discovery.rs` HELLO framing → `specs/discovery.t27` counter/timer skeleton.
- `src/daemon.rs` framing FSM → `specs/daemon.t27` for the state transitions.
- ETX and GF16 stay blocked on t27#1258 (array/RAM support in the bootstrap parser).

`crypto.rs` (X25519, ChaCha20-Poly1305) and `modem.rs` RX DSP (float pipeline) remain out of scope by design — T27 is an integer hardware-datapath language.

## Regeneration recipe

```
# On a machine with t27c on $PATH (after t27#1320 merges into t27c):
cd tri-net
t27c gen-rust specs/wire.t27 > gen/rust/wire.rs.new

# Diff against the committed output:
diff gen/rust/wire.rs gen/rust/wire.rs.new

# Promote (see the rule below — regen overwrites the whole file by design):
mv gen/rust/wire.rs.new gen/rust/wire.rs
cargo test --lib && cargo test --test m2_routing_pure_logic
```

Pre-validated 2026-07-04 against the post-t27#1320 t27c: regen overwrites the
whole file (~49 lines removed / ~40 added vs the hand-touched committed
version), NOT just the stub band. Tests stay 101/0 + 25/0. The bigger diff is
expected and correct: it removes the now-obsolete `ExprCast` banner and the
hand-stubs, and normalises cosmetic rendering to raw t27c output.

## Rule: `gen/` is untouchable raw output

`gen/rust/*` (and `gen/<lang>/*` generally) is the deterministic output of
`t27c`. It is never hand-edited — no banners, no comments, no cosmetic cleanups,
no stub bands. Anything explanatory (migration notes, caveats, status banners)
belongs in the **consumer** (`src/wire.rs` module doc) or in this migration
doc, never in the generated file.

If `t27c` emits something wrong, the fix is upstream in `t27c` itself
(e.g. t27#1320 for `ExprCast`), never a patch to `gen/`. Once the upstream fix
lands, regenerate and the whole `gen/` file becomes canonical raw output.

Diff-shape lesson from this PR: a "stub-only" regen diff is only possible when
`gen/` was never hand-touched. The moment any hand-edit (even a documentation
banner) lands in `gen/`, every later regen rewrites it — which is correct
behaviour, not a surprise. Keep `gen/` pure.
2 changes: 1 addition & 1 deletion docs/T27_PORT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on the generated Verilog, matching the Rust module's tests.

| Rust module | T27 spec | Status | Notes |
|---|---|---|---|
| `src/wire.rs` | `specs/wire.t27` | ✅ PORTED | 11-byte header logic; 8 tests pass in vvp; iverilog-clean |
| `src/wire.rs` | `specs/wire.t27` | ✅ T27-FIRST (partial) | Constants + predicates auto-generated into `gen/rust/wire.rs` via `t27c gen-rust`; byte-layout stays hand-written; 101 lib tests + 25 M2 pure-logic tests still green. See `docs/T27_FIRST_MIGRATION.md` |
| `src/modem.rs` (BPSK core) | `t27/specs/fpga/bpsk.t27` | ✅ PORTED | modulator + Barker-13 correlator (on t27 master) |
| `src/modem.rs` (RX DSP: RRC/timing/CFO) | — | ❌ NOT PORTABLE | floating-point; T27 is integer |
| `src/routing.rs` (ETX metric) | `specs/etx.t27` | ⬜ TODO | integer/fixed math ports; dynamic tables need array/RAM (t27#1258) |
Expand Down
63 changes: 63 additions & 0 deletions gen/rust/wire.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Generated from .t27 spec
// DO NOT EDIT — generated by t27c

pub const VERSION: u8 = 1;

pub const KIND_HELLO: u8 = 0;

pub const KIND_DATA: u8 = 1;

pub const HEADER_LEN: usize = 11;

pub fn frame_kind_valid(k: u8) -> bool {
return (k <= KIND_DATA);
}

pub fn be_byte(w: u32, i: usize) -> u8 {
if (i == 0) {
return (((w >> 24) & 255) as u8);
} else {
if (i == 1) {
return (((w >> 16) & 255) as u8);
} else {
if (i == 2) {
return (((w >> 8) & 255) as u8);
} else {
return ((w & 255) as u8);
}
}
}
}

pub fn u32_be(b0: u8, b1: u8, b2: u8, b3: u8) -> u32 {
return (((((b0 as u32) << 24) | ((b1 as u32) << 16)) | ((b2 as u32) << 8)) | (b3 as u32));
}

pub fn header_byte(kind: u8, src: u32, dst: u32, ttl: u8, idx: usize) -> u8 {
if (idx == 0) {
return VERSION;
} else {
if (idx == 1) {
return kind;
} else {
if (idx <= 5) {
return be_byte(src, (idx - 2));
} else {
if (idx <= 9) {
return be_byte(dst, (idx - 6));
} else {
return ttl;
}
}
}
}
}

pub fn parse_accepts(b0: u8, b1: u8) -> bool {
if (b0 == VERSION) {
return frame_kind_valid(b1);
} else {
return false;
}
}

70 changes: 58 additions & 12 deletions src/wire.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,48 @@
//! Mesh datagram header. Serialized bytes double as the AEAD associated data,
//! so a tampered header fails authentication in [`crate::crypto::Session::open`].
//!
//! Anchor: phi^2 + phi^-2 = 3.
//!
//! # T27-first partial flip
//!
//! Constants (`VERSION`, `KIND_HELLO`, `KIND_DATA`, `HEADER_LEN`) and pure
//! predicates (`frame_kind_valid`, `header_byte`, `parse_accepts`) live in
//! `specs/wire.t27` and are auto-generated into `gen/rust/wire.rs` via the
//! t27c bootstrap compiler. This module re-exports them and wraps them in
//! ergonomic Rust types. See `docs/T27_FIRST_MIGRATION.md`.

use crate::routing::NodeId;

pub const VERSION: u8 = 1;
// Auto-generated from specs/wire.t27 by t27c gen-rust.
// The t27c-0.1.0 emitter produces literal `return` statements and extra
// parentheses around every expression. This is idiomatic for the T27 language
// but not for Rust, so we scope clippy/rustc lints down here rather than
// hand-editing the generated file (gen/ is untouchable; see
// docs/T27_FIRST_MIGRATION.md). Cleaner Rust rendering is upstream work on
// gHashTag/t27 (needless_return / unnecessary_parens in expr_to_rust).
#[allow(clippy::needless_return, unused_parens)]
pub mod gen {
include!("../gen/rust/wire.rs");
}

pub use gen::{
frame_kind_valid, header_byte, parse_accepts, HEADER_LEN, KIND_DATA, KIND_HELLO, VERSION,
};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FrameKind {
Hello = 0,
Data = 1,
Hello = KIND_HELLO as isize,
Data = KIND_DATA as isize,
}

impl FrameKind {
fn from_u8(b: u8) -> Option<Self> {
if !frame_kind_valid(b) {
return None;
}
match b {
0 => Some(FrameKind::Hello),
1 => Some(FrameKind::Data),
x if x == KIND_HELLO => Some(FrameKind::Hello),
x if x == KIND_DATA => Some(FrameKind::Data),
_ => None,
}
}
Expand All @@ -31,7 +58,7 @@ pub struct Header {
}

impl Header {
pub const LEN: usize = 11;
pub const LEN: usize = HEADER_LEN;

pub fn new(kind: FrameKind, src: NodeId, dst: NodeId, ttl: u8) -> Self {
Self {
Expand All @@ -44,16 +71,18 @@ impl Header {

pub fn to_bytes(&self) -> [u8; Self::LEN] {
let mut b = [0u8; Self::LEN];
b[0] = VERSION;
b[1] = self.kind as u8;
b[2..6].copy_from_slice(&self.src.to_be_bytes());
b[6..10].copy_from_slice(&self.dst.to_be_bytes());
b[10] = self.ttl;
let kind = self.kind as u8;
for (i, slot) in b.iter_mut().enumerate() {
*slot = header_byte(kind, self.src, self.dst, self.ttl, i);
}
b
}

pub fn parse(b: &[u8]) -> Option<Self> {
if b.len() < Self::LEN || b[0] != VERSION {
if b.len() < Self::LEN {
return None;
}
if !parse_accepts(b[0], b[1]) {
return None;
}
Some(Self {
Expand Down Expand Up @@ -81,4 +110,21 @@ mod tests {
b[0] = 99;
assert!(Header::parse(&b).is_none());
}

#[test]
fn t27_gen_constants_match_hand_written() {
assert_eq!(VERSION, 1);
assert_eq!(KIND_HELLO, 0);
assert_eq!(KIND_DATA, 1);
assert_eq!(HEADER_LEN, 11);
}

#[test]
fn t27_gen_predicates_match_semantics() {
assert!(frame_kind_valid(KIND_HELLO));
assert!(frame_kind_valid(KIND_DATA));
assert!(!frame_kind_valid(2));
assert!(parse_accepts(VERSION, KIND_HELLO));
assert!(!parse_accepts(99, KIND_HELLO));
}
}
Loading