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.
This commit is contained in:
alex 2026-09-08 09:43:46 -04:00
parent 496a5124c9
commit 6589a3985f
2 changed files with 93 additions and 18 deletions

View file

@ -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.

View file

@ -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<const char*>(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<std::int32_t>(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<std::int32_t>(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<std::int32_t>(r, kNpType) == 0) {
++st.permanent;
continue;
}
if (peek<std::int32_t>(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<Tv>& 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<Tv>& out, void* self, vo
const char* last = static_cast<const char*>(ptr_at(results, 4));
if (first && last && last >= first) result_count = static_cast<std::int32_t>((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<Tv>& 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<Tv>& 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);
}