From 6589a3985f9e63a1d30c0f4a5440a1140f6b1551 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 09:43:46 -0400 Subject: [PATCH] Z: the run, and one falsified prediction of my own Eight End Turns across two saves. A turn costs 18-22 generator words, all of it inside StrategyServer::ProcessTurn; the tail costs 0; the residual outside the two drivers is exactly 0 on every complete bracket. The generator does not move between turns at all, so the interval a standalone has to reproduce is closed at both ends. Checked against the save files, not just against itself: the turn-6 autosave pair gives 18 words read from the two Sim.RNG blobs, with twists == 0 -- so that number never passes through a twist implementation and the agreement is about the game rather than about two copies of one algorithm. P6 was wrong. S+0x8 advances 12-14 times per turn, not twice; both drivers are hooked so the other increments come from somewhere unidentified. Lane K's 'at least twice' was right and its conclusion is strengthened. Node-line decay still has not fired, and the hook now says how far away it is rather than that it did not happen: 51 of 53 lines are permanent, the mortal ones are dug ~1/turn by the Zuul, and each is ~40 turns from expiry. --- docs/Z-tail-rng.md | 30 ++++++++++++++ src/shim/hooks/tail_rng.cpp | 81 ++++++++++++++++++++++++++++--------- 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/docs/Z-tail-rng.md b/docs/Z-tail-rng.md index 963433f..9ad6838 100644 --- a/docs/Z-tail-rng.md +++ b/docs/Z-tail-rng.md @@ -120,3 +120,33 @@ unmodelled subtrees, not a prediction dressed as one. * The ledger measures **words**, not draws. A `NextInt` that rejects three times is four words and one draw; this instrument reports four and cannot tell you it was one call. That is the right unit for save-state reproduction and the wrong unit for counting decisions. + +--- + +## 5. Outcome (added after the run; nothing above was edited) + +Build `z-tailrng-20260908T1314Z` on `ref-turn2` (4 End Turns) and `z-tailrng2-20260908T1328Z` on +`zuul-turn16-noderoute`. Full report: `sots-re/findings/control-flow/tail-rng-ledger.md`. + +| # | prediction | outcome | +|---|---|---| +| P1 | the tail runs on a turn with no combat | **held, half** — one call per End Turn on 8 of 8. But every turn had exactly one *encounter* (a sighting, `res->+0x4 != 0`), so what is proved is "no battle", not "no encounter". Narrowed, not closed | +| P2 | quiet turn → tail consumes 0 words | **held** — 0 on 8 of 8 | +| P3 | tail = 0 on the reference saves; the defect is latent | **held** | +| P4 | `ProcessTurn` spends a small nonzero state-dependent count, order 1–20 | **held** — 18, 19, 20, 22 across eight turns | +| P5 | residual > 0 in general, **0 on a quiet turn** | **held** — residual exactly 0 on all six complete brackets | +| P6 | `S+0x8` advances exactly twice per turn | **FALSIFIED** — it advances **12–14** times per turn; the two drivers are 2 of them. Lane K's "at least twice" was the right phrasing and its conclusion (never treat `S+0x8` as a turn number) is strengthened | +| P7 | node-line expiry probably will not fire | **held**, and quantified rather than left as an absence: 51 of 53 lines on the Zuul map are permanent (`npt == 0`), the mortal ones are dug by the Zuul at ~1/turn, and every one is ~40 turns from expiry | +| P8 | one generator, every observation resolves | **held** — no `words: null` in any record | + +Two results worth more than the predictions: + +* **The generator does not move between turns.** Each turn's `ProcessTurn` entry position equals the + previous post-turn autosave position exactly. The interval a standalone must reproduce is closed. +* **The ledger was checked against the save files.** The turn-6 autosave pair gives 18 words read straight + out of the two `Sim.RNG` blobs — and with `twists == 0`, so that number does not go through anyone's twist + implementation. The live hook said 18. + +Two corrections went back into `combat-done-tail.md` in place: the node-line fleet check runs *after* the +`Chance` call and cannot gate the draw, and `StrategyHost::Autosave` is `ret 8` returning the `std::string*` +in EAX. diff --git a/src/shim/hooks/tail_rng.cpp b/src/shim/hooks/tail_rng.cpp index 4184e46..a348b83 100644 --- a/src/shim/hooks/tail_rng.cpp +++ b/src/shim/hooks/tail_rng.cpp @@ -217,13 +217,29 @@ std::int32_t node_path_remaining_life(const void* rec, std::int32_t turn) { return rem > 0 ? rem : 0; } +// How close the node-line population is to producing a draw at all. Without this, "we ran N turns +// and phase 11 never fired" is an anecdote; with it, the report can say whether any reachable save +// could have fired it and how far away the nearest line is. `min_life` is the smallest positive +// remaining life over the lines that can expire at all -- the number of turns of pure ageing +// between this save and the first phase-11 draw, if nothing adds traffic. +struct NodePathStats { + std::int32_t paths = -1; + std::int32_t expired = -1; // remaining life <= 0 -> one word each + std::int32_t permanent = -1; // npt == 0: never expires + std::int32_t immortal = -1; // npdtn == INT_MAX: never expires + std::int32_t mortal = -1; // the rest: the only ones that can ever draw + std::int32_t min_life = -1; // smallest positive remaining life among the mortal ones + std::int32_t within5 = -1; // mortal lines with remaining life <= 5 +}; + // Words node-line decay 0x007ae010 will consume, predicted from the state at entry. The gate on the draw is // the expiry test and nothing else: the `Chance` result and the "a fleet with flag 0x20000 is // riding this line" scan both run AFTER the draw at 0x007ae095 and gate only the collapse. // (That corrects combat-done-tail.md §3, which reads as if the fleet check suppressed the roll.) // Returns -1 when the state could not be read, which is reported as unknown rather than as 0. -std::int32_t predicted_node_line_words(void* self, std::int32_t* out_paths) { - if (out_paths) *out_paths = -1; +std::int32_t predicted_node_line_words(void* self, NodePathStats* out) { + NodePathStats st; + if (out) *out = st; if (!readable(self, kServerOffNodeGraph + 4)) return -1; const char* graph = static_cast(ptr_at(self, kServerOffNodeGraph)); if (!readable(graph, kGraphOffPaths + 8)) return -1; @@ -234,13 +250,43 @@ std::int32_t predicted_node_line_words(void* self, std::int32_t* out_paths) { if (span % kNodePathStride != 0) return -1; const std::size_t n = span / kNodePathStride; if (n > kMaxNodePaths || !readable(first, span)) return -1; - if (out_paths) *out_paths = static_cast(n); const std::int32_t turn = turn_of(self); - std::int32_t expired = 0; - for (std::size_t i = 0; i < n; ++i) - if (node_path_remaining_life(first + i * kNodePathStride, turn) <= 0) ++expired; - return expired; + st.paths = static_cast(n); + st.expired = st.permanent = st.immortal = st.mortal = st.within5 = 0; + st.min_life = -1; + for (std::size_t i = 0; i < n; ++i) { + const char* r = first + i * kNodePathStride; + if (peek(r, kNpType) == 0) { + ++st.permanent; + continue; + } + if (peek(r, kNpLife) == 0x7fffffff) { + ++st.immortal; + continue; + } + ++st.mortal; + const std::int32_t life = node_path_remaining_life(r, turn); + if (life <= 0) { + ++st.expired; + continue; + } + if (life <= 5) ++st.within5; + if (st.min_life < 0 || life < st.min_life) st.min_life = life; + } + if (out) *out = st; + return st.expired; +} + +void push_node_path_args(std::vector& out, const NodePathStats& st, std::int32_t predicted, + const char* predict_name) { + out.push_back(tv::i32(st.paths).named("node_paths")); + out.push_back(tv::i32(st.permanent).named("np_permanent")); + out.push_back(tv::i32(st.immortal).named("np_immortal")); + out.push_back(tv::i32(st.mortal).named("np_mortal")); + out.push_back(tv::i32(st.min_life).named("np_min_life")); + out.push_back(tv::i32(st.within5).named("np_within5")); + out.push_back(tv::i32(predicted).named(predict_name)); } // Advance a scratch copy of the RNG object by `words` words, the way the original would. @@ -278,7 +324,7 @@ CallState g_autosave, g_process_turn, g_tail, g_apply, g_nodespace; struct NodeLineState { RngEntry entry; std::int32_t predicted = -1; - std::int32_t paths = -1; + NodePathStats stats; void* s_rng = nullptr; bool compare = false; }; @@ -453,8 +499,8 @@ void OnAllCombatDoneTailHook::describe_args(std::vector& out, void* self, vo const char* last = static_cast(ptr_at(results, 4)); if (first && last && last >= first) result_count = static_cast((last - first) / 0x178); } - std::int32_t paths = -1; - const std::int32_t predicted = predicted_node_line_words(self, &paths); + NodePathStats st; + const std::int32_t predicted = predicted_node_line_words(self, &st); out.push_back(tv::ptr(self).named("server")); out.push_back(tv::ptr(results).named("results")); @@ -462,8 +508,7 @@ void OnAllCombatDoneTailHook::describe_args(std::vector& out, void* self, vo push_server_args(out, self); // The tail's only predictable draw source, evaluated before the tail runs: if phase 11 is // the whole story on a quiet turn, the tail's measured word delta equals this number. - out.push_back(tv::i32(paths).named("node_paths")); - out.push_back(tv::i32(predicted).named("predict_nodeline_words")); + push_node_path_args(out, st, predicted, "predict_nodeline_words"); push_rng_args(out, g_tail.entry); } @@ -560,14 +605,14 @@ void ApplyEncounterResultHook::coverage(trace::Coverage& c) { void NodeLineDecayHook::describe_args(std::vector& out, void* self) { g_nld.entry = observe_entry(self); - g_nld.paths = -1; - g_nld.predicted = predicted_node_line_words(self, &g_nld.paths); + g_nld.predicted = predicted_node_line_words(self, &g_nld.stats); out.push_back(tv::ptr(self).named("server")); push_server_args(out, self); - out.push_back(tv::i32(g_nld.paths).named("node_paths")); - // Written before the original runs: this is the falsifiable claim, not a report of what - // happened. If the measured delta on `rng` is not this number, the model is wrong. - out.push_back(tv::i32(g_nld.predicted).named("predict_words")); + // `predict_words` is written before the original runs: it is the falsifiable claim, not a + // report of what happened. If the measured delta on `rng` is not this number, the model is + // wrong. The np_* fields say how close the population is to firing at all, so "phase 11 + // never drew" can be reported as a distance rather than as an absence. + push_node_path_args(out, g_nld.stats, g_nld.predicted, "predict_words"); push_rng_args(out, g_nld.entry); }