diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..f9e6ff32 --- /dev/null +++ b/build.rs @@ -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"); +} diff --git a/docs/T27_FIRST_MIGRATION.md b/docs/T27_FIRST_MIGRATION.md new file mode 100644 index 00000000..51dc866a --- /dev/null +++ b/docs/T27_FIRST_MIGRATION.md @@ -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//*` 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. diff --git a/docs/T27_PORT_STATUS.md b/docs/T27_PORT_STATUS.md index e7405142..a39e472f 100644 --- a/docs/T27_PORT_STATUS.md +++ b/docs/T27_PORT_STATUS.md @@ -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) | diff --git a/gen/rust/wire.rs b/gen/rust/wire.rs new file mode 100644 index 00000000..404a8a16 --- /dev/null +++ b/gen/rust/wire.rs @@ -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; + } +} + diff --git a/src/wire.rs b/src/wire.rs index 6704ea0b..271943f2 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -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 { + 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, } } @@ -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 { @@ -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 { - 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 { @@ -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)); + } }