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:
parent
496a5124c9
commit
6589a3985f
2 changed files with 93 additions and 18 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue