EXPcaliburINSIDE THE MACHINE

07 / ABYSS / ORIENTATION

ABYSS engine: a second machine

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

PilotReads the arena and asks to move or aim.
ABYSS workSubmits inner-machine work for host checks and saved proof.
WorldUses accepted power and actions to resolve fights and gem pickup.

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
  1. Tick 0: HELLO returns a public bank; no proof yet.
  2. Tick 1: first and completion calls for SERRATE; the host can save its Entry proof.
  3. Tick 2: the same pair for BASTION.
  4. Tick 3: the same pair for TRAWL.
  5. Tick 4 onward: a RUN plus DRIVE [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 uploaded artifact picks an engine before tick oneThe uploaded artifact picks an engine before tick oneUploaded artifactRunner reads the raw file once.Ordinary image → CLASSICOuter VM behavior feeds namedmeters and capped effects.Recognized wrapper → ABYSSOuter VM can request checked innerwork and temporary Drive.Same world: actions → combat → drops → picked-up EXPMode belongs to the upload, not to bytes later changed inside guest memory.
The mode fork happens once, before the match. The runner reads the uploaded artifact, fixes CLASSIC or ABYSS, then starts the same outer VM and arena loop. The exact 32-byte choice is below. View diagram at full size

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:

RUST crates/runner/src/lib.rs · source lines 115–141
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:

RUST crates/gate/src/mode.rs · source lines 30–41
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:

RUST crates/vm/src/lib.rs · source lines 1239–1246
                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);
Two switches must line up before an ABYSS request can even be checked
BEFORE THE MATCH · UPLOAD FILEValid 32-byte 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.
DURING A TICK · VM FETCHDoes the VM execute byte 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.
AFTER THE CALL · HOSTDoes the request pass?Gate checks the copied request. For work-bearing calls, it also checks the inner run. 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.

The Entry reference file, from its first byte
File bytes 0–7 · signature45 58 50 43 44 56 34 00ASCII EXPCDV4\0
8–11 · key ID04 00 00 00Little-endian 4
12–15 · ruleset ID04 00 00 00Little-endian 4
16–27 · reserved00 × 12All zero in this file
28–31 · check19 78 DF B6First four SHA-256 bytes of bytes 0–27

From 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:

RUST crates/gate/src/mode.rs · source lines 65–87
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:

PYTHON bots/exploit/abyss_entry/generate.py · source lines 409–412
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.

BOT bots/exploit/abyss_entry/abyss_entry.bot · source lines 809–809
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:

BOT bots/exploit/abyss_entry/abyss_entry.bot · source lines 3235–3241
fn main() {
  let p0 = 0x007ffc01;
  let p1 = 0x01080700;
  let p2 = 0x00002104;
  let p3 = 0x7ffc0121;
  return run_gate();
}
PYTHON bots/exploit/abyss_entry/generate.py · source lines 396–406
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.

RUST crates/vm/src/lib.rs · source lines 1259–1265
                    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.

PYTHON bots/exploit/abyss_entry/generate.py · source lines 98–138
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
BOT bots/exploit/abyss_entry/abyss_entry.bot · source lines 2331–2333
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:

PYTHON bots/exploit/abyss_entry/generate.py · source lines 129–138
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.

One Entry attempt: what lasts after each stepOne Entry attempt: what lasts after each step1SEND2PAUSE3CHECK4DRIVE5EXPIRECopiedrequestEntry fibre(saved first call)Saved proof(host receipt)Active Drive(short boost)Fixed inputFirst call + ownerAccepted history stays for the runPower is liveboost ends+ EXPA boost may help the fight. Only a real gem pickup changes EXP.
One Entry attempt: what lasts after each step. The copied request belongs to one call. Entry's fibre holds the first call and owner until completion. Accepted proof stays for this run, but Drive power ends when it expires. The knight earns EXP separately through gem pickup. View diagram at full size
1 · SENDOne request is copiedThe bot may still edit its own memory, but this call uses the host's fixed copy.
2 · PAUSEEntry saves its first halfThe saved fibre holds the first call, owner, and work count for the later completion call.
3 · CHECKAccepted proof staysA passing host check saves a receipt for this run. A failed later upgrade does not erase it.
4 · DRIVEA short boost is activeThe bot asks to use its proof. The game applies only the allowed level.
5 · EXPIREPower ends; proof remainsAnother allowed Drive request can refresh the boost. EXP still needs a picked-up gem.

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.

One game tick can contain several Gate calls
GAME TICK N
outer VM runsGate call 1Gate call 2outer VM continues
These calls share a game tick. Crossing a Gate-call boundary does not advance the world's tick counter.
01 · OUTER VMRan out of its tick budget?

Save the bot's instruction position, stack pointer, and memory. Continue that same bot on the next game tick.

Stop: up to 10,000 VM instructions Resume: next tick
02 · ENTRY PAIRFinished call one?

Save the completed first-call report, who owns it, and 31 work steps. The matching second Entry call checks and finishes that pair.

Stop: first Entry call Resume: second Entry call
03 · THREADED GATEInner program yielded?

Keep its live registers, stacks, next cell, and spent work. A later admitted RUN continues the same inner program.

Stop: YIELD or call fuel Resume: next admitted RUN

Same 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:

RUST crates/gate/src/abyss/state.rs · source lines 74–101

#[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:

RUST crates/gate/src/abyss/threaded.rs · source lines 1896–1909
        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:

RUST crates/gate/src/abyss/wire.rs · source lines 327–357
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.

Three checks before proof is saved
Safe to readAddress and public fields are valid.
Did the right workThe request and inner run pass the required check.
Save proofThe host checks earlier links and stores the accepted result together.
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.

  1. The host safely copies the request. The first check passes.
  2. The deeper work check fails. No stronger proof is saved.
  3. The old accepted proof remains valid.
  4. The old boost continues only until its original expiry.
  5. 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
Entry EntryProofTwo readings of saved input and limited execution. Level limit: 4.
Rift CalibratedProofOrdered costs tied to this run. Level limit: 8.
Chasm StaleDispatchProofSaved work and resource identity after a pause. Level limit: 12.
Full ABYSS apex 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.

Same answer, three different receipts

Assume the host checked and accepted each example. All three programs returned 7, but the accepted work has a different identity.

RECEIPT ASeed A · SERRATEDualView passed

This receipt belongs to this seed, check kind, and SERRATE evidence.

CHANGE THE SEEDSeed B · SERRATEDualView passed

The owner root changes. Receipt A cannot stand in for this accepted check.

CHANGE THE TRACKSeed A · TRAWLDualView passed

Entry's evidence names TRAWL, and its saved proof node is scoped to TRAWL.

Same calculated 7≠same host-accepted proof

After 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:

RUST crates/gate/src/abyss/provenance.rs · source lines 192–207
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.