Save the completed first-call report, who owns it, and 31 work steps. The matching second Entry call checks and finishes that pair.
Surprise: the challenge has a second machine inside it. Your outer VM still steers the knight. In ABYSS, though, the bot can also send work to an inner machine. The host checks that work before it allows a short power boost. More power helps, but EXP still comes only from gems the knight collects.
Input: a request from the outer bot.
Saved state: owners, resources, waiting work, accepted proof, and a temporary boost.
Output: limited attack, survival, and collection levels. EXP still comes from gems the knight picks up.
First separate the two jobs of the bot
The bot has two separate jobs. The pilot decides where to move and aim, while ABYSS submits inner-machine work that can earn limited power. Neither job can do the other's work. Proof will not steer the knight around a wall, and clever steering will not create proof.
Two jobs meet at the same knight
Proof is a host-saved receipt for checked work. It is not a command to add EXP.
See who reads and uses each result
| Part | Reads | Produces | Who uses the result? |
|---|---|---|---|
| Outer pilot | Knight, enemies, gems, map observations | Movement and aim requests, including moves toward gems | World simulation |
| ABYSS work | Accepted request context and inner machine state | Proposed result | Host checker |
| Power check | Saved accepted proof | Allowed levels | Temporary Drive setting and reward calculation |
| Arena | Actions plus current effects | Combat, drops, collection, EXP | Next observation and final result |
Follow the whole trip. Checked inner work can raise damage, and stronger hits can kill more enemies, which may drop gems. The knight then has to collect those gems, because only collected gems add EXP. The inner program cannot skip those steps.
Follow one Entry bot before opening the machine
The Entry reference bot shows that route from start to finish. Later sections show how it builds the file and sends requests. Three separate records sit under the scene. Saved proof is accepted work the host remembers, and active Drive is power the knight can use now. EXP rises only when the knight picks up a gem.
The bot seeks three kinds of power. SERRATE helps its sword, BASTION helps survival and movement, and TRAWL helps it collect gems. All three apply to the same knight.
Select the numbered stages in order. Each one shows its place in the Entry bot's tick schedule. The world panel shows what an accepted boost could do, not a replay of a real kill.
Watch Entry proof reach the knight
One upload moves through mode choice, checked work, a short boost, and finally the ordinary game loop.
See the tick schedule as a list
- Tick 0:
HELLOreturns a public bank; no proof yet. - Tick 1: first and completion calls for SERRATE; the host can save its Entry proof.
- Tick 2: the same pair for BASTION.
- Tick 3: the same pair for TRAWL.
- Tick 4 onward: a
RUNplusDRIVE [4,4,4]batch, then the pilot acts. The short boost needs refreshing; EXP still needs gem pickup.
Keep that route in mind as we zoom in. The wrapper below selects ABYSS. A changed instruction then opens the request path, and the bank and body decide whether Entry proof passes. Later chapters open the host checks and power limits.
How ABYSS joins the run
The runner selects the engine before the match, much like choosing a game mode at the start screen. Once the run starts, no write to the bot's memory can switch engines.
The source calls the second mode DeviceV4; this guide calls it the ABYSS engine. The runner fixes that choice before it starts the VM. For ABYSS, it creates a Gate state from the raw upload's hash and the world seed.
That state lasts for the whole match. CLASSIC's chain payouts are off in this mode. The bot can send requests to that state but cannot switch engines mid-match. Here is the runner's fork:
fn prepare_artifact(bot: &[u8]) -> Result<expc_gate::Artifact, RunError> {
let art = expc_gate::parse_artifact(bot);
if art.mode == EconomyMode::DeviceV4 && !protocol_ids_match(art.key_id, art.ruleset_id) {
return Err(RunError::UnsupportedDeviceProtocol);
}
Ok(art)
}
fn run_prepared(seed: u32, art: &expc_gate::Artifact, record: bool) -> SingleRun {
let out = if art.mode.device_v4_on() {
run_bot_device_with_context(
DeviceRunContext {
raw_image_digest: art.raw_digest,
world_seed: seed,
},
&art.code,
record,
false,
)
} else {
run_bot(seed, &art.code, record, false)
};
SingleRun {
result: out.result,
replay: out.replay,
}
}What reveals a second surface?
A high score is a clue, not a map. Start instead from the player handout and follow its clues. During the CTF, players had compiled tools and a sample bot, not this Rust source. Now the source lets us check each step.
First clue: the upload is a file, not just instructions. The handout gives bin/expc-cc, bin/expc-run, and bots/sample.bin. Its README says the server takes a raw .bin upload and the local runner uses the same VM and game engine. The compiler and sample show how to make a normal CLASSIC bot. They do not prove, though, that every byte of an upload must be a VM instruction.
Second clue: the runner hides two markers. Scan the bundled expc-run as bytes with rg -aob 'EXPCDV4|BRD1' bin/expc-run. In this handout build, the scan finds 406710:EXPCDV4 and 691738:BRD1. EXPCDV4 starts the ABYSS upload wrapper, while BRD1 starts a Gate request frame.
The same EXPCDV4 scan finds nothing in expc-cc or sample.bin. Each offset is only a position inside this runner file, not a guest-memory address. A string match does not reveal the header or request layout yet. It just tells you where to look next.
Third clue: the marker selects the mode before the bot runs. In the released source, the upload parser creates an Artifact. It stores the engine choice, the unwrapped VM image, a hash of the whole upload, and the protocol IDs. In other words, the parser makes the choice, not the running bot:
pub struct Artifact {
pub mode: EconomyMode,
pub code: Vec<u8>,
pub raw_digest: [u8; 32],
pub key_id: u32,
pub ruleset_id: u32,
}Fourth clue: instruction 0x21 calls the Gate. The VM treats this byte as a Gate call only in ABYSS mode. The bot supplies a request address, and the VM passes guest memory and its remaining work budget to the host. The host returns a status word and may reject the request. Either way, the call alone does not change the score:
op::SYSCALL => self.do_syscall(host),
op::GATECALL if self.mode.device_v4_on() => {
let req_ptr = self.pop();
let budget = PER_TICK_INSTR_BUDGET.saturating_sub(executed);EXPCDV4\0 wrapper?Yes → runner selects ABYSS and removes the wrapper from the VM image.No → runner selects CLASSIC and uses the entire upload as VM bytes.
0x21?In ABYSS, it calls Gate. In CLASSIC, it is an invalid opcode if execution reaches it. A 0x21 sitting unused in data does nothing.HELLO only returns public data. Accepted proof can later support a short boost; gems still make EXP.no wrapper + no executed 0x21Ordinary CLASSIC path.no wrapper + executed 0x21Still CLASSIC; that opcode is invalid if reached.valid wrapper + no executed 0x21ABYSS selected, but no Gate request was made.valid wrapper + executed 0x21Gate receives a request; proof is still not automatic.Before any proof can start, two things must happen. The upload needs a valid wrapper, and the VM must execute 0x21. The next sections build those two pieces; only then can the Gate check a request.
Build the file that selects ABYSS
A normal compiled bot is just VM instructions, and the Entry bot puts a 32-byte wrapper in front of them. The runner reads that wrapper once and removes it from the executable image. Then the VM starts at address zero of the remaining program. That makes the wrapper a mode choice, not an instruction the bot executes.
45 58 50 43 44 56 34 00ASCII EXPCDV4\004 00 00 00Little-endian 404 00 00 00Little-endian 400 × 12All zero in this file19 78 DF B6First four SHA-256 bytes of bytes 0–27From file byte 32 onward: the public compiler's VM image. Its first byte becomes guest address 0.
The parser requires the exact signature and a matching four-byte check. If either check fails, it treats the whole upload as CLASSIC instructions, wrapper and all. The production runner, however, rejects a valid wrapper with unsupported key or ruleset IDs.
The raw-file hash includes the wrapper, so wrapped and unwrapped uploads have different identities. These are the actual parser branches:
pub fn parse_artifact(upload: &[u8]) -> Artifact {
let raw_digest = sha256(upload);
let is_device = upload.len() >= DEVICE_HEADER_LEN
&& upload[0..8] == DEVICE_MAGIC
&& upload[28..32] == header_check(&upload[0..28]);
if is_device {
Artifact {
mode: EconomyMode::DeviceV4,
code: upload[DEVICE_HEADER_LEN..].to_vec(),
raw_digest,
key_id: u32::from_le_bytes([upload[8], upload[9], upload[10], upload[11]]),
ruleset_id: u32::from_le_bytes([upload[12], upload[13], upload[14], upload[15]]),
}
} else {
Artifact {
mode: EconomyMode::ClassicLegacy,
code: upload.to_vec(),
raw_digest,
key_id: 0,
ruleset_id: 0,
}
}
}The Entry generator writes the wrapper by joining a fixed 28-byte prefix to that SHA-256 check. The table above uses bytes from its saved abyss_entry.bin, not an invented example:
def wrap_device(raw: bytes) -> bytes:
header = DEVICE_HEADER_PREFIX + hashlib.sha256(DEVICE_HEADER_PREFIX).digest()[:4]
return header + raw
Make a Gate call with public-language source
Selecting ABYSS makes the Gate available, but the public language has no gatecall() function. How, then, does the stock-compiled Entry bot call it? It changes an instruction inside its own 64 KiB guest memory, where function instructions and local variables share the same bytes. If a local store lands on function bytes, the next call reads the changed instruction.
The generator adds harmless filler until gate_once starts at guest address 0x2004. Its legal source says return ~0x2104, so the compiler puts BIT_NOT (0x51) at 0x2010. And 0x2104 is where run_gate keeps its request frame.
fn gate_once() { return ~0x2104; }Then main declares four ordinary local variables. Their compiled stores write to 0x2004..0x2013, which is the first 16 bytes of gate_once. The chosen values leave 15 of those bytes unchanged. Only byte 0x2010 changes, from 0x51 to 0x21. In ABYSS, 0x21 means GATECALL.
One changed byte opens Gate
These are the real first 16 bytes of gate_once in the saved Entry bot. Select a moment to see what the compiler emitted and what the bot runs.
These are the four values in the saved source. The generator reads the compiler's first-pass bytes and changes byte 12 of that 16-byte window. Then it converts the result back into four little-endian words. A layout check comes first, so a compiler change stops the build instead of quietly patching the wrong place:
fn main() {
let p0 = 0x007ffc01;
let p1 = 0x01080700;
let p2 = 0x00002104;
let p3 = 0x7ffc0121;
return run_gate();
}def derive_patch_words(pass_zero: bytes) -> tuple[int, int, int, int]:
original = pass_zero[GATE_ONCE : GATE_ONCE + 16]
expected = bytes.fromhex("01 fc 7f 00 00 07 08 01 04 21 00 00 51")
if original[:13] != expected:
raise AssertionError("stock gate_once layout changed")
patched = bytearray(original)
patched[12] = 0x21
return tuple(
int.from_bytes(patched[offset : offset + 4], "little")
for offset in range(0, 16, 4)
)Now run_gate calls gate_once(). The changed instruction pops the frame address and hands the frame and guest memory to the Gate. Then it pushes back a masked status. The compiler's surrounding call and return code still works. No host-machine code changes. This is a self-edit inside the small guest VM.
let packed = host.gatecall(req_ptr, budget, &mut self.mem);
let status = packed & GATE_STATUS_MASK;
let steps = (packed >> GATE_STEPS_SHIFT).min(GATE_STEPS_MAX);
self.push_tagged(status, true);
executed = executed
.saturating_add(GATECALL_BUDGET_COST)
.saturating_add(steps);The saved build trace records zero changes after compilation, so the payload after the wrapper is exactly the public compiler's output. The byte changes only when the bot runs, through the compiler's own local stores. In CLASSIC the same 0x21 is invalid. So this route needs both pieces: the ABYSS wrapper and the runtime byte change.
Where the Entry bot gets its bank-specific bytes
The first Gate call does not grant power. Instead, the Entry bot asks HELLO for a public bank number. Then it picks the matching test bytes. Its saved fixture holds 16 banks × 3 power tracks = 48 records.
Each record contains a nonce and a 64-byte body. The generator hides these words in the immediate values of ordinary compiled instructions. The bot's gem_value helper reads them back through the VM's wrapping address rule. Despite its name, the helper reads the bot's own program, not the game's gem score.
def read_witnesses() -> list[Witness]:
raw = FIXTURE.read_bytes()
expected_header = (b"ABYENTR1", 1, 48, 181, 75, 5)
if FIXTURE_HEADER.unpack_from(raw) != expected_header:
raise ValueError("ABYSS fixture header changed")
if len(raw) != FIXTURE_HEADER_LEN + FIXTURE_COUNT * FIXTURE_RECORD_LEN:
raise ValueError("ABYSS fixture length is inconsistent")
result: list[Witness] = []
for index in range(FIXTURE_COUNT):
start = FIXTURE_HEADER_LEN + index * FIXTURE_RECORD_LEN
record = raw[start : start + FIXTURE_RECORD_LEN]
result.append(
Witness(
bank=record[0],
lineage=record[1],
nonce=struct.unpack_from("<I", record, 3)[0],
body=bytes(record[11:75]),
mutant_body=bytes(
record[
FIXTURE_MUTANT_BODY_OFFSET :
FIXTURE_MUTANT_BODY_OFFSET + 64
]
),
)
)
expected_order = [(bank, lineage) for bank in range(16) for lineage in range(3)]
if [(w.bank, w.lineage) for w in result] != expected_order:
raise ValueError("fixture records are not canonically ordered")
return result
def corpus_words(witnesses: list[Witness], mutant: bool) -> list[int]:
result: list[int] = []
for witness in witnesses:
body = witness.mutant_body if mutant else witness.body
result.append(witness.nonce)
result.extend(struct.unpack("<16I", body))
if len(result) != 16 * 3 * 17:
raise AssertionError("witness corpus has the wrong word count")
return result
fn witness_word(bank, lineage, index) {
return gem_value(2361 + bank*51 + lineage*17 + index);
}The lookup formula is 2361 + bank × 51 + track × 17 + word. Why 51? One bank has three tracks, and each track uses 17 words: one nonce plus sixteen four-byte body words.
For bank 0, track 0, word 0, gem_value(2361) computes 0xC204 + 2361 × 12. The VM wraps that address to 0x30B0, where the first saved value sits. The next 16 lookups read the 64-byte body.
The opening storyboard already showed this schedule. The reference bot waits for a fresh game tick between two-call Entry pairs. Its tick barrier also stops a third Gate call within one tick. The generator's trace and the Entry README record this schedule.
Within each pair, the host checks the copied body, the row and column rules, the saved pause, and the owner. The next chapter opens those checks one by one. A saved test runs the first working rung through the real VM and Gate.
A control bot shows which part actually matters
Once the byte patch makes the Gate call work, is the job done? A second bot in the repository answers that question. It keeps the same public compiler, header, runtime byte patch, Gate schedule, and pilot. Only the 48 body records are different.
The new records still pass the row reading and keep the same conditioning, but they fail the column reading. The generator makes that one change here:
def corpus_words(witnesses: list[Witness], mutant: bool) -> list[int]:
result: list[int] = []
for witness in witnesses:
body = witness.mutant_body if mutant else witness.body
result.append(witness.nonce)
result.extend(struct.unpack("<16I", body))
if len(result) != 16 * 3 * 17:
raise AssertionError("witness corpus has the wrong word count")
return result
| Saved test | Gate access and schedule | Body accepted by both readings? | Three allowed power levels |
|---|---|---|---|
| Entry reference | Same header, byte patch, and two-call timing | Yes | [4,4,4] |
| Parser-blind control | Same header, byte patch, and two-call timing | No | [0,0,0] |
The real-VM test runs both bots once for each of the 16 public HELLO banks. The control can call the Gate, but its work earns no power. That is the lesson. A Gate call and a valid-looking frame are only the entrance. The host saves Entry proof only when the 64-byte body passes both host readings.
The repository's saved runs give a hidden-score median of 10,102 for Entry and 303 for the control. These are measured runs, not a claim about every seed.
What state survives a request?
Two inner programs can both return 12. One did the required work; the other only loaded 12. To tell them apart, ABYSS saves who ran the work, what it used, and what happened before a pause.
Follow one successful Entry attempt. The host copies the first call and saves its result, then a second Gate call finishes the pair. The Entry bot makes both calls in the same game tick. If the check passes, the host saves proof, which can support a short Drive boost. The diagram shows what remains after each step.
Each of these objects lasts for a different time. A copied request is not a fibre, and a fibre is not accepted proof. Proof is not an active Drive setting, and Drive is not EXP.
This matters after a failed upgrade. A boost that is still active may just be the old setting, running until its original expiry. It does not mean the new work passed.
Three pause buttons, three different clocks
“It paused” is not enough to debug ABYSS. First ask: which machine stopped? The outer bot and the inner Gate have different budgets and saved states. Also, a second Gate call does not start a new game tick. The Entry bot makes each two-call pair within one tick, and the Apex bot does the same with two RUN calls.
Save the bot's instruction position, stack pointer, and memory. Continue that same bot on the next game tick.
Keep its live registers, stacks, next cell, and spent work. A later admitted RUN continues the same inner program.
YIELD or call fuel Resume: next admitted RUNSame word, different save point. Entry stores a finished first half; threaded Gate stores an unfinished running program. Neither pause by itself moves the knight or adds EXP.
Start with the outer VM, which runs the uploaded bot. When its per-tick budget ends, the VM saves its instruction position (ip), stack pointer (sp), and guest memory. On the next world tick, the bot continues instead of restarting main. But this outer pause says nothing about whether the Gate accepted a request.
Now zoom into Entry. The first call runs its fixed first-half check and returns after 31 counted steps. The host then saves a Fibre holding the state, work count, owner, and an EntryFirstCall report. That report contains the first-half trace and results.
This fibre is not a live cursor waiting for the next cell of a threaded program. The second call must match the saved owner and body. Only then can the host finish Entry and consider saving proof. Here is the saved structure:
#[derive(Clone, Copy, PartialEq, Eq)]
struct Fibre {
state: FibreState,
semantic_steps_total: u16,
entry_owner: Option<EntryFibreOwner>,
entry_first: Option<EntryFirstCall>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct EntryFibreOwner {
reserved_slot: u8,
reserved_generation: u32,
expected_lineage: u8,
bank: u8,
nonce: u32,
body_digest: [u8; 32],
}
impl Fibre {
const fn idle() -> Self {
Self {
state: FibreState::Idle,
semantic_steps_total: 0,
entry_owner: None,
entry_first: None,
}
}The threaded Gate machine, used later in ABYSS, really is an inner instruction program. Its saved ThreadedMachine holds the running lanes with their registers and stacks. It also keeps the next cell, the trace, and total work. A YIELD cell or a spent call budget leaves the machine in Yielded state. The next admitted RUN then resumes it from the saved cell.
Each call can use at most 256 work steps, and never more than what is left of the game tick's shared Gate budget. Over its whole life, one machine can use at most 512 steps. The Apex bot's 505-step program is an example: 256 in the first call and 249 in the second. Both calls happen in one game tick. The source shows the allowance and where a call stops:
let lifetime_remaining = MAX_FIBRE_STEPS - self.semantic_steps_total;
let allowance = available_tick_fuel
.min(MAX_CALL_FUEL)
.min(lifetime_remaining);
let mut used = 0u16;
loop {
if used == allowance {
self.state = MachineState::Yielded;
return CallResult {
semantic_steps: used,
stop: CallStop::Yielded(YieldCause::CallFuel),
};
}When a trace stops at 256, ask which budget ran out. Did the Gate call spend its fuel, or did the outer bot's game tick end? If the Gate call ran out, another admitted RUN can resume the inner program on the same tick. If the outer tick ended, the bot must wait for the next game tick. Entry's first-call pause is a third case: it expects its matching completion call, not a threaded RUN.
| State | Concrete job | Why the next request needs it |
|---|---|---|
| Session | Names the current interaction | The next request must belong to the same owner and power track |
| Capability | A handle with specific permissions | Reusing a numbered slot must not turn an old handle into a new resource |
| Work queue | Holds approved work waiting to run | Sending work and running it can happen at different times |
| Entry fibre | Holds the first Entry call, its owner, and steps spent | The completion call must match that saved first half |
| Threaded Gate machine | Holds paused instruction execution later in ABYSS | Registers, stacks, next cell, and work spent must continue |
| Proof arena | A table of accepted proof, linked to earlier proof | Later checks can require earlier passes |
| Drive setting | Current boost levels and expiry tick | Proof can stay after its temporary boost ends |
All of these objects live under one run's GateState, where space is limited. It has 32 capability slots, 16 waiting jobs, 64 saved proof records, and eight 1,024-byte Gate pages. Slots can be reused, so “slot 3” may name a different object later, and the host must check more than the slot number.
Connection to the next chapter: the scheduler owns these relationships. The executor owns the computation admitted under them.
The request boundary: freeze the bytes first
The outer bot can edit its guest memory. If the host reads that memory twice, it might see different bytes. It could then check one request but use another.
What the host does: it copies the checked frame and limited payload into its own storage. The wire module shows this step:
impl OwnedInvocation {
pub fn copy_from_guest(guest: &[u8], request_pointer: u16) -> Result<Self, StructuralError> {
let frame = copy_guest_frame(guest, request_pointer)?;
let decoded = BraidFrame::decode(&frame)?;
let descriptor = decoded.input_descriptor();
let range = descriptor
.checked_range()
.ok_or(StructuralError::InputRange)?;
let source = guest
.get(range.as_range())
.ok_or(StructuralError::InputRange)?;
let output = decoded
.output_descriptor()
.checked_range()
.ok_or(StructuralError::OutputRange)?;
if guest.get(output.as_range()).is_none() {
return Err(StructuralError::OutputRange);
}
let mut input = [0u8; INPUT_MAX];
input[..source.len()].copy_from_slice(source);
Ok(Self {
frame,
input,
input_len: descriptor.length,
})
}From then on, the saved request is frozen, meaning later guest writes cannot change it. But the copy fixes only which bytes the host reads, not whether the request is valid. Each reader still has to accept what those bytes mean.
The dual-view issue: same bytes, complementary checks
The 64-byte Entry body is four 4×4 tiles. RowCursor reads across the rows, while ColumnCursor reads down the columns of the same saved bytes. Each reading faces its own rules. The tile diagram and code show the exact order.
| Question | Protection that answers it |
|---|---|
| Can the bot change input after the host copies it? | No: the host uses its saved copy |
| Do both readings pass their own rules? | Separate row and column checks for this request |
| Does the accepted work belong to this run and proof track? | Owner binding and linked proof |
The two-reading check is a first step toward proof, but it does not set damage or EXP. The copy stops the bot from changing bytes mid-check. The row and column rules then decide whether those fixed bytes count.
Acceptance has three separate stages
The host checks three things in order. First, can it read the request safely? Next, did the request do the required work? Finally, can the host save proof for this owner? Passing the first check says nothing about the other two.
| Stage | Checks | Result if it fails |
|---|---|---|
| Safe to read | Memory range, frame length, request shape | Reject bad input |
| Required work | Specific check for this accepted request | No stronger proof; failure can be quiet |
| Save proof | Earlier links, owner, track, space, consistency | No half-saved power |
An accepted frame passes only the first check, and a run without a crash does not prove that the other checks passed.
Work a failed upgrade through existing state
Say the run already has valid level-4 proof and a level-4 boost that has not expired. It tries to earn more.
- The host safely copies the request. The first check passes.
- The deeper work check fails. No stronger proof is saved.
- The old accepted proof remains valid.
- The old boost continues only until its original expiry.
- Combat can therefore still look powerful, despite the failed upgrade.
So quick kills do not prove that the upgrade worked, because the old boost might still be active. To explain a new score, follow new accepted proof → applied power → changed world events.
The four rungs are a dependency ladder
Each later step needs proof from earlier work, like levels in a game. The reference bots use the short names on the left, while the source code uses the record names beside them. These are two names for each step, not two separate ladders.
The four name pairs are shown as cards below →
| Reference-bot rung | Saved proof record in code | New check at this step | Step's level limit |
|---|---|---|---|
| Entry | EntryProof |
Two readings of one saved input and limited execution | 4 |
| Rift | CalibratedProof |
Ordered cost observations tied to this run | 8 |
| Chasm | StaleDispatchProof |
Saved work and resource identity after a pause | 12 |
| Full ABYSS apex | ModuleProof |
The inner module's checked work | 16 |
EntryProofTwo readings of saved input and limited execution. Level limit: 4.CalibratedProofOrdered costs tied to this run. Level limit: 8.StaleDispatchProofSaved work and resource identity after a pause. Level limit: 12.ModuleProofChecked inner-module work. Level limit: 16.Reaching a step sets a maximum level, not an instant boost. The proof chapter adds a second maximum, based on how many different checks have passed. The smaller of the two applies.
Why a retained dispatch history differs from dual interpretation
Entry reads one saved input in two ways. The saved-dispatch check asks a different question. After the work pauses, does it still belong to the right session, resource, and host check?
Suppose the host reuses a table slot: its number stays the same, but it now holds a new object. To catch that, ABYSS compares the saved and current identities, owners, pages, and permissions. It checks again before it copies the program page.
The special route uses a saved dispatch handle. If that handle passes its check, the host marks the execution ExecutionSource::StaleDispatch and uses the StaleBootstrap checker. That checker examines what happened across the pause and the resource reuse. It also requires earlier cost-check proof.
The queue timeline follows the slot through that change, and the Gate chapter compares the two routes.
Binding answers “the same work, for whom?”
A result alone does not identify the work behind it. Suppose three inner programs each calculate 7. Which run did each one belong to? Was the host checking SERRATE's sword work or TRAWL's gem work? Did the host even accept the check? The 7 answers none of those questions; here, 7 is only an example, not a captured match result.
Assume the host checked and accepted each example. All three programs returned 7, but the accepted work has a different identity.
DualView passedThis receipt belongs to this seed, check kind, and SERRATE evidence.
DualView passedThe owner root changes. Receipt A cannot stand in for this accepted check.
DualView passedEntry's evidence names TRAWL, and its saved proof node is scoped to TRAWL.
7≠same host-accepted proofAfter a check passes, the host makes a 16-byte binding. Think of it as a fingerprint of the accepted evidence. The host hashes four pieces in order: TAG_CERT, the 12-byte owner root, one check-kind byte, and the checked evidence. For Entry, that evidence includes the power track, session, nonce, body fingerprint, and the relevant row or execution trace.
The saved proof node also records its power track. So changing the seed, check kind, or track changes what the receipt describes. Merely returning the same number cannot carry proof across.
Here is the host's binding recipe. Its input is evidence that has already passed a check. A bot that hashes its own guest bytes does not make the host accept them:
pub(super) fn derive_certificate_binding(
owner_root: [u8; 12],
kind: CertificateKind,
canonical_evidence: &[u8],
) -> Result<[u8; 16], ProvenanceError> {
if canonical_evidence.len() > MAX_CERTIFICATE_EVIDENCE {
return Err(ProvenanceError::CertificateEvidenceTooLong);
}
let mut message = [0u8; 16 + 12 + 1 + MAX_CERTIFICATE_EVIDENCE];
let used = 29 + canonical_evidence.len();
message[0..16].copy_from_slice(&TAG_CERT);
message[16..28].copy_from_slice(&owner_root);
message[28] = kind.as_u8();
message[29..used].copy_from_slice(canonical_evidence);
Ok(prefix16(sha256(&message[..used])))
}One detail matters if you reproduce this calculation. The owner_root comes from the fixed protocol values and the world seed, not from the uploaded file digest. As a result, with the same seed, changing only the upload leaves this root unchanged.
The raw-upload digest feeds other run values, such as the relocation root and the challenge bank. A fixed tag keeps certificate hashes apart from those other hashes. The same complete inputs always give the same binding. The host needs no fresh random secret for each match.
Where this chapter leaves the computation
We have now seen how the host copies requests and saves state, and how a safe read differs from accepted proof. That leaves one concrete question: what does the inner machine actually change when it runs?
The Gate chapter answers it with registers, three small stacks, cells, work limits, resource checks, and return values. The proof chapter then shows what the host saves. The economy chapter shows how allowed power affects the knight.