Technical Reference
For plugin developers, integrators, and the morbidly curious.
Contents
1. Architectural overview
Krebf is two pieces joined at a narrow waist:
- A PHP plugin shell that provides the shortcode, REST endpoints, admin page, activation hooks, and security boundaries.
- A self-contained JavaScript Z-machine interpreter (roughly 2,500 lines, 87 KB) that runs in the browser, wrapped in an IIFE so it cannot collide with theme scripts.
The PHP layer never touches Z-machine bytecode. Its only job is to
(a) keep untrusted file references from escaping the configured games
directory, (b) optionally proxy external HTTP(S) fetches through WordPress's
safe HTTP API, and (c) hand the player a per-request configuration object
via wp_add_inline_script. Once the browser has the binary and
the configuration, the interpreter runs entirely client-side — saves
to localStorage, transcripts to Blob downloads, nothing
more touches the server.
2. File map
krebf/
├── krebf.php — main plugin file (bootstrap, constants)
├── readme.txt — WordPress.org format
├── uninstall.php — drops options on plugin removal
├── includes/
│ ├── class-krebf-security.php — validation, path checks, SSRF defence
│ ├── class-krebf-plugin.php — singleton, asset registration
│ ├── class-krebf-shortcode.php— [krebf] renderer
│ ├── class-krebf-rest.php — /list, /game, /fetch endpoints
│ └── class-krebf-admin.php — Settings → Krebf page
├── templates/
│ └── player.php — HTML for a single player instance
├── assets/
│ ├── js/krebf.js — interpreter (IIFE, 87 KB)
│ └── css/krebf.css — styles scoped to .krebf-root (15 KB)
└── games/
├── index.php — silence (shipped; real runtime folder
│ is wp-content/uploads/krebf-games/)
├── .htaccess — deny-all so Apache/LiteSpeed refuse
│ direct HTTP access to binaries
└── README.txt — in-repo note on the actual runtime path
3. Security model
Five lines of defence, applied in this order to every request that could touch a file or open a network connection:
A. Capability check for admin writes
The settings page is gated behind current_user_can('manage_options')
and all four registered options go through register_setting()'s
sanitisation callbacks, which the Settings API invokes after verifying the
request's nonce — nothing custom to misconfigure.
B. Slug validation
User-supplied game identifiers pass through
Krebf_Security::classify_game_param(). A slug must satisfy:
preg_match('/^[A-Za-z0-9][A-Za-z0-9._\-]*$/', $slug)basename($slug) === $slug(no separators)- Length ≤ 128 characters
- No control characters, NULs, or
..sequences
C. Path-traversal boundary
Every resolved path is canonicalised with realpath() and
checked to sit under the canonicalised games directory and
WP_CONTENT_DIR. Symlinks are refused outright. The check is
in Krebf_Security::resolve_slug():
$real = realpath( $candidate );
$rdir = realpath( $games_dir );
if ( strpos( $real, $rdir ) !== 0 ) continue;
if ( is_link( $real ) ) continue;
D. SSRF defence on external fetches
For /fetch the plugin resolves the hostname itself with
gethostbynamel(), then filters every returned IP through
FILTER_VALIDATE_IP with FILTER_FLAG_NO_PRIV_RANGE |
FILTER_FLAG_NO_RES_RANGE. If any resolved address is
non-public the URL is refused before WordPress's HTTP API sees it,
closing the DNS-rebinding-to-private-network SSRF.
E. Content validation
Served bytes — whether from a local file or an external fetch —
have their first byte inspected. If it isn't 1–8 (a valid Z-machine
version number), the response is a 422 Unprocessable Entity,
not a download. This catches phishing sites that pretend to host
.z3 but actually serve HTML.
4. REST API
All three endpoints live under the namespace krebf/v1.
GET /wp-json/krebf/v1/list
Returns the public game list. No authentication.
200 OK
{
"games": [
{ "slug": "cloak.z5", "title": "Cloak", "size": 33822, "version": 5 },
{ "slug": "zork1.z3", "title": "Zork 1", "size": 84640, "version": 3 }
]
}
GET /wp-json/krebf/v1/game?slug=SLUG
Serves the story file as application/octet-stream. No
authentication — but the slug must resolve to an actual file in the
games folder.
200 OK
Content-Type: application/octet-stream
Content-Length: 84640
Content-Disposition: inline; filename="zork1.z3"
X-Content-Type-Options: nosniff
Cache-Control: public, max-age=3600
<raw Z-code bytes>
Error responses:
404— slug did not resolve to a file in the folder.422— file exists but first byte isn't a valid Z-machine version.500— file system read error.
GET /wp-json/krebf/v1/fetch?url=URL
Proxies an external URL through WordPress's HTTP API. Requires a valid
X-WP-Nonce header (or _wpnonce query parameter)
even for logged-out visitors; the shortcode injects this nonce into
window.krebfInstances[id].nonce.
Error responses:
403— external fetches disabled, host not on allow-list, SSRF rejection, or bad nonce.413— response exceeds 512 KB.415— remote responded with HTML/XHTML (not a story file).422— response body doesn't begin with a valid Z-machine version byte.502— upstream transport error or non-2xx response.
5. Shortcode internals
The shortcode builds one configuration object per instance, serialises
it into an inline script, and enqueues the stylesheet + interpreter
lazily (only when a page actually contains [krebf]). The
shape of the config object handed to JavaScript:
window.krebfInstances["krebf-1"] = {
restNamespace: "krebf/v1",
restBase: "https://example.com/wp-json/krebf/v1/",
nonce: "…wp_rest nonce…",
pluginUrl: "https://example.com/wp-content/plugins/krebf/",
games: [ { slug: "...", title: "...", size: N } , ... ],
instanceId: "krebf-1",
initial: { kind: "slug", slug: "zork1" } | { kind: "url", url: "…" } | null
};
Notice what is not in that object: the absolute filesystem path of the games directory, any server paths at all, any credentials. Even the optional admin allow-list for external URLs stays server-side — the client just tries, and the server rejects with 403 if disallowed.
6. Option schema
| Option key | Type | Default | Purpose |
|---|---|---|---|
krebf_games_dir | string | (empty) | Absolute path to the games folder. Empty means "use
uploads/krebf-games/". Must be inside
WP_CONTENT_DIR. |
krebf_allow_external | bool | 0 |
Whether ?game=https://... URLs are honoured. |
krebf_external_hosts | string | (empty) | Newline-separated allow-list of hostnames. Empty = any public host. |
krebf_default_game | string | (empty) | Slug or HTTPS URL loaded when a [krebf] page is
visited without explicit ?game= or attribute. |
7. Hooks and filters
In addition to the three register_* and four register_setting calls, Krebf exposes no custom filters in 1.0.1. Extensibility lives in two directions:
- Override the shortcode output by unregistering
[krebf]in your theme and re-registering under your own function, callingKrebf_Shortcode::render_template()with your own template file. - Add new options or endpoints in a child plugin
that includes
class-krebf-security.phpand reuses the validation helpers. The security class is designed to be composed with, not reimplemented.
8. Theming (CSS variables)
Every colour and border is driven by a CSS custom property on
.krebf-root. Override them from your theme:
.krebf-root {
--krebf-amber: #ffb547; /* primary accent */
--krebf-amber-bright:#ffd89a; /* hover / focus */
--krebf-amber-dim: #8a5a1f; /* secondary text */
--krebf-amber-glow: rgba(255, 181, 71, 0.35); /* text-shadow */
--krebf-brass: #c08a3e;
--krebf-brass-dark: #6b4a20;
--krebf-bg-deep: #0a0806; /* outer frame */
--krebf-bg-panel: #141210; /* interior panels */
--krebf-bg-panel-2: #1c1915; /* raised surfaces */
--krebf-cream: #f4e4c1; /* main text */
--krebf-rule: rgba(255, 181, 71, 0.15); /* borders */
--krebf-red-led: #ff5a3c; /* transcript LED */
--krebf-green-led: #5aff8c; /* I/O LED */
}
The --krebf- prefix means these variables will never
collide with your theme's own custom properties.
9. Z-machine interpreter
Direct port of Fredrik Ramsberg's Ruby interpreter (github.com/fredrikr/krebf); the code organisation follows the original closely for auditability. When debugging an interpreter quirk, the Ruby source is the authoritative reference.
State model
At the top of the IIFE live the module-level variables that mirror the
Ruby $globals:
let z, dynmemBackup, zcodeVersion, pc, storyfileName, quit,
screenObj, streams, stack,
objectTable, baseDictionary, routineOffset, stringOffset,
abbrevTable, globalBase, alphabet, defaultUnicode, finalUnicode,
undoData, font, transcriptBit,
args, instruction,
rndA, rndB, rndC, rndX; // PRNG (Ozmoo-compatible)
Classes
ScreenClass— character grid, windows, cursor, style runs, buffered word-wrap. Rendered into<div class="krebf-screen">on each frame viainnerHTMLwith escaped content.StreamsClass— output streams 1 (screen), 2 (transcript), 3 (memory), 4 (command log); input streams 0 (keyboard), 1 (file). Routes every character through the correct combination.StackClass— the call stack plus routine frames. Each frame carries locals, its own eval-stack, a return PC, a store byte, and the argument count.stackForSave()produces a deep-clonable snapshot for the save/restore opcodes.
Main loop
async function runMainLoop() {
quit = false;
krebfSetLed('led-run', true);
while (!quit) {
instruction = readInstruction(); // parse
const fn = opcodeRoutines[type][opcodeNumber];
const result = fn(); // dispatch
if (result && result.then) await result; // async I/O?
// …transcript/header housekeeping…
if (++stepsSinceYield >= 5000) {
await krebfYield(); screenObj.render();
}
}
}
The yield every 5,000 instructions stops long scripts from locking up
the browser. krebfYield() is a setTimeout(resolve, 0)
promise, which returns control to the event loop between iterations.
PRNG
The random number generator is a deterministic port of Ozmoo's algorithm
for exact compatibility. Seeded state lives in rndA/B/C/X and
advances via the same four-word update Ruby performs. Verified against the
reference implementation: seed 1 produces 200, 109, 74, 59, 246.
10. Opcode coverage
All five opcode tables from the Z-machine spec are implemented in full. Table cardinalities match the source:
| Table | Entries | Coverage |
|---|---|---|
| 0OP | 15 | 100% (rtrue, rfalse, print, print_ret, nop, save/restore, restart, ret_popped, pop/catch, quit, new_line, show_status, verify, piracy) |
| 1OP | 16 | 100% (jz, get_sibling, get_child, get_parent, get_prop_len, inc, dec, print_addr, call_1s, remove_obj, print_obj, ret, jump, print_paddr, load, not/call_1n) |
| 2OP | 29 | 100% (comparisons, jumps, get_prop/next_prop, arithmetic, load/store, clear_attr/set_attr/test_attr, insert_obj, loadw/loadb, put_prop, call_2s/2n, set_colour, throw) |
| VAR | 32 | 100% (call_vs/vn, storew/storeb, read, print_char/num, random, push/pull, split/set_window, erase_window/line, sound_effect, read_char, tokenise, encode_text, copy_table, print_table, check_arg_count, …) |
| EXT | 14 | save_z5, restore_z5, log_shift, art_shift, set_font, save_undo, restore_undo, print_unicode, check_unicode (v5+ extensions). |
Dispatch
The interpreter decodes form bytes (long/short/variable/extended) into
{ opcode_type, opcode_number, operand_values } and indexes
directly into opcodeRoutines[type][number]. Unimplemented
entries are null; hitting one calls fatalErr()
with the opcode coordinates.
11. Known limits
- Version 6 (graphical Z-machine) not supported — no window/font metrics implementation in the source.
- Blorb container format not yet parsed. Extract the Z-code chunk and load it directly.
- Multiple players per page not possible; the
interpreter uses module-level state. Second
[krebf]shortcode on a page is ignored with a console warning. - Sampled sound effects not implemented. The
sound_effectopcode triggers a short WebAudio tone. - Mid-input restart: clicking Restart while the game is awaiting input takes effect after the current read resolves, which may leave a spurious input line.
12. Contributing
Bug reports, v6 patches, Blorb parser contributions, i18n translations, and theme variants all welcome.
- Original Krebf (Ruby interpreter): issues and pull requests at github.com/fredrikr/krebf. Contact: Fredrik Ramsberg.
- JavaScript port and WordPress plugin: contact Bill Martens via callapple.org, the publisher of record for this edition.
Bugs that reproduce against the Ruby reference belong upstream; bugs specific to browser rendering, the WordPress plugin, or the JavaScript port belong here.