diff --git a/.eslintignore b/.eslintignore index dbf2a1896f..bb2a67194c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -13,5 +13,6 @@ markdown electron tools src/ThirdParty +src/Arcade/Zork/vendor webpack.config.js diff --git a/.gitattributes b/.gitattributes index 6313b56c57..7db2bc9212 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text=auto eol=lf +*.z3 binary diff --git a/.prettierignore b/.prettierignore index b94299a92c..bd19e222a6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -23,3 +23,7 @@ tsdoc-metadata.json src/Documentation/pages.ts # This file is generated by tools/bundle-doc/generate-math-notation-output.mjs src/Documentation/data/MathNotationOutput.json + +# Verbatim vendored MIT-licensed interpreter files (see src/Arcade/Zork/LICENSE.md) +src/Arcade/Zork/vendor +src/Arcade/Zork/games diff --git a/jest.config.js b/jest.config.js index 4e6fe89b15..c718b3ce46 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,6 +12,7 @@ module.exports = { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/test/__mocks__/fileMock.js", "\\.(css|less)$": "/test/__mocks__/NullMock.js", + "\\.z3$": "/test/__mocks__/fileMock.js", ".*?raw$": "/test/__mocks__/fileMock.js", "@player": "/src/Player", "@enums": "/src/Enums", diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index b68b0f01ce..036efae56a 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -15,6 +15,10 @@ declare module "*.svg" { const value: string; export default value; } +declare module "*.z3" { + const value: string; + export default value; +} // Achievements communicated back to Electron shell for Steam. declare interface Document { diff --git a/src/Arcade/Zork/GlkOteReact.ts b/src/Arcade/Zork/GlkOteReact.ts new file mode 100644 index 0000000000..6e133631d6 --- /dev/null +++ b/src/Arcade/Zork/GlkOteReact.ts @@ -0,0 +1,253 @@ +/** + * A GlkOte-protocol display implementation that feeds React state instead of + * manipulating the DOM (replaces glkote.js, which requires jQuery). + * vendor/glkapi.js calls the GlkOte contract methods; the terminal UI calls + * the send* methods. Protocol: https://eblong.com/zarf/glk/glkote/docs.html + */ +import type { ZorkDialog } from "./ZorkDialog"; + +export interface StyledRun { + style: string; + text: string; +} + +export interface TerminalState { + gridLines: StyledRun[][]; + bufferLines: StyledRun[][]; + inputRequest: { id: number; gen: number; type: "line" | "char"; maxlen: number; initial: string } | null; + filePrompt: { filemode: string; filetype: string } | null; + disabled: boolean; + error: string | null; +} + +export type TerminalListener = (state: TerminalState) => void; + +type RunOrString = string | { style?: string; text?: string }; +interface WindowUpdate { + id: number; + type: "grid" | "buffer" | "graphics"; + rock: number; + gridwidth?: number; + gridheight?: number; +} +interface ContentUpdate { + id: number; + clear?: boolean; + lines?: { line: number; content?: RunOrString[] }[]; + text?: { append?: boolean; flowbreak?: boolean; content?: RunOrString[] }[]; +} +interface InputUpdate { + id: number; + gen: number; + type: "line" | "char"; + maxlen?: number; + initial?: string; +} +export interface GlkOteUpdate { + type: "update" | "pass" | "retry" | "error"; + gen?: number; + windows?: WindowUpdate[]; + content?: ContentUpdate[]; + input?: InputUpdate[]; + specialinput?: { type: string; filemode: string; filetype: string }; + disable?: boolean; + message?: string; +} +interface GlkOteEvent { + type: string; + gen: number; + [key: string]: unknown; +} +interface AcceptInterface { + accept: (event: GlkOteEvent) => void; +} + +// Fixed 80x25 terminal metrics; glkapi derives grid rows/cols from these. +const METRICS = { + width: 720, + height: 450, + outspacingx: 0, + outspacingy: 0, + inspacingx: 0, + inspacingy: 0, + gridcharwidth: 9, + gridcharheight: 18, + gridmarginx: 0, + gridmarginy: 0, + buffercharwidth: 9, + buffercharheight: 18, + buffermarginx: 0, + buffermarginy: 0, +}; + +// Cap scrollback so multi-hour sessions don't grow unbounded. +const MAX_BUFFER_LINES = 2000; + +function parseRuns(content: RunOrString[] | undefined): StyledRun[] { + const runs: StyledRun[] = []; + if (!content) return runs; + let i = 0; + while (i < content.length) { + const item = content[i]; + if (typeof item === "string") { + // Alternating "style", "text" string pairs. + const text = content[i + 1]; + runs.push({ style: item, text: typeof text === "string" ? text : "" }); + i += 2; + } else { + runs.push({ style: item.style ?? "normal", text: item.text ?? "" }); + i += 1; + } + } + return runs; +} + +export class GlkOteReact { + private readonly dialog: ZorkDialog; + private listener: TerminalListener | null; + private iface: AcceptInterface | null = null; + private generation = 0; + private windows = new Map(); + private state: TerminalState = { + gridLines: [], + bufferLines: [], + inputRequest: null, + filePrompt: null, + disabled: false, + error: null, + }; + + constructor(dialog: ZorkDialog, listener: TerminalListener) { + this.dialog = dialog; + this.listener = listener; + } + + /* ---- GlkOte contract, called by vendor/glkapi.js ---- */ + + init(iface: AcceptInterface): void { + this.iface = iface; + iface.accept({ type: "init", gen: 0, metrics: METRICS, support: [] }); + } + + update(arg: GlkOteUpdate): void { + if (arg.type === "error") { + this.state.error = arg.message ?? "Unknown interpreter error"; + this.emit(); + return; + } + if (arg.type !== "update") return; + if (typeof arg.gen === "number") this.generation = arg.gen; + + if (arg.windows) { + this.windows.clear(); + for (const win of arg.windows) this.windows.set(win.id, win); + } + if (arg.content) { + for (const update of arg.content) this.applyContent(update); + } + // Input requests fully replace the previous set each update. + this.state.inputRequest = null; + if (arg.input) { + for (const req of arg.input) { + const win = this.windows.get(req.id); + // Zork only reads from the buffer window; prefer it if several ask. + if (!this.state.inputRequest || win?.type === "buffer") { + this.state.inputRequest = { + id: req.id, + gen: req.gen, + type: req.type, + maxlen: req.maxlen ?? 200, + initial: req.initial ?? "", + }; + } + } + } + this.state.filePrompt = + arg.specialinput?.type === "fileref_prompt" + ? { filemode: arg.specialinput.filemode, filetype: arg.specialinput.filetype } + : null; + this.state.disabled = !!arg.disable || (!this.state.inputRequest && !this.state.filePrompt); + this.emit(); + } + + log(__msg: string): void { + // Interpreter debug output: intentionally dropped. + } + warning(msg: string): void { + console.warn("Zork glkapi warning:", msg); + } + error(msg: string): void { + this.state.error = String(msg); + this.emit(); + } + getlibrary(name: string): unknown { + return name === "Dialog" ? this.dialog : null; + } + getinterface(): AcceptInterface | null { + return this.iface; + } + save_allstate(): undefined { + return undefined; // autosave not enabled + } + + /* ---- Outgoing events, called by the terminal UI / session ---- */ + + sendLine(text: string): void { + const req = this.state.inputRequest; + if (!this.iface || !req || req.type !== "line") return; + this.iface.accept({ type: "line", gen: this.generation, window: req.id, value: text }); + } + + sendChar(key: string): void { + const req = this.state.inputRequest; + if (!this.iface || !req || req.type !== "char") return; + this.iface.accept({ type: "char", gen: this.generation, window: req.id, value: key }); + } + + sendFileref(value: { filename: string; usage: string } | null): void { + if (!this.iface) return; + this.iface.accept({ type: "specialresponse", gen: this.generation, response: "fileref_prompt", value }); + } + + detach(): void { + this.listener = null; + this.iface = null; + } + + /* ---- Internals ---- */ + + private applyContent(update: ContentUpdate): void { + const win = this.windows.get(update.id); + if (!win) return; + if (win.type === "grid") { + const height = win.gridheight ?? 1; + while (this.state.gridLines.length < height) this.state.gridLines.push([]); + this.state.gridLines.length = height; + for (const line of update.lines ?? []) { + if (line.line >= 0 && line.line < height) this.state.gridLines[line.line] = parseRuns(line.content); + } + } else if (win.type === "buffer") { + if (update.clear) this.state.bufferLines = []; + for (const para of update.text ?? []) { + const runs = parseRuns(para.content); + if (para.append && this.state.bufferLines.length > 0) { + this.state.bufferLines[this.state.bufferLines.length - 1].push(...runs); + } else { + this.state.bufferLines.push(runs); + } + } + if (this.state.bufferLines.length > MAX_BUFFER_LINES) { + this.state.bufferLines = this.state.bufferLines.slice(-MAX_BUFFER_LINES); + } + } + } + + private emit(): void { + if (!this.listener) return; + this.listener({ + ...this.state, + gridLines: this.state.gridLines.map((l) => [...l]), + bufferLines: this.state.bufferLines.map((l) => [...l]), + }); + } +} diff --git a/src/Arcade/Zork/LICENSE.md b/src/Arcade/Zork/LICENSE.md new file mode 100644 index 0000000000..ec2ec0d731 --- /dev/null +++ b/src/Arcade/Zork/LICENSE.md @@ -0,0 +1,97 @@ +# Licenses for src/Arcade/Zork/ + +Everything in this folder (and only this folder) is MIT-licensed third-party +material plus Bitburner glue code. This license file applies solely to the +contents of `src/Arcade/Zork/`. The rest of Bitburner is licensed separately — +see `license.txt` in the repository root. + +--- + +## ZVM interpreter — `vendor/zvm.js` + +From ifvms.js 1.1.6 (), part of the +Parchment project. Distributed under the MIT license per the project's LICENSE +file and package metadata. (Note: a few internal module headers in the bundled +file carry an older "BSD licenced" notice from the same authors; the project as +a whole is distributed under MIT.) + +> The MIT License +> +> Copyright (c) 2011-2017 Dannii Willis and other contributors +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +--- + +## Glk API layer — `vendor/glkapi.js` + +From GlkOte 2.3.7 (). + +> The MIT License +> +> Copyright (c) 2008-2025, Andrew Plotkin +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +--- + +## Zork I, II, III — `games/*.z3` + +Compiled Z-machine story files for Zork I, Zork II, and Zork III, open-sourced +under the MIT license (, +, +). + +> MIT License +> +> Copyright (c) 2025 Microsoft +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. diff --git a/src/Arcade/Zork/README.md b/src/Arcade/Zork/README.md new file mode 100644 index 0000000000..af74fab7af --- /dev/null +++ b/src/Arcade/Zork/README.md @@ -0,0 +1,39 @@ +# Zork in the Arcade + +Zork I–III cabinets for the New Tokyo Arcade, powered by the Parchment +project's pure-JS Z-machine interpreter (ZVM). + +## Contents + +- `vendor/zvm.js` — ifvms.js 1.1.6 `dist/zvm.js`, verbatim. Do not edit or format. +- `vendor/glkapi.js` — GlkOte 2.3.7 `glkapi.js`, verbatim. Do not edit or format. +- `games/*.z3` — compiled Zork I/II/III story files (MIT, Microsoft's 2025 + open-source release via github.com/historicalsource). +- Everything else — Bitburner glue code: React terminal UI (`ZorkTerminal.tsx`), + GlkOte display adapter (`GlkOteReact.ts`), localStorage save layer + (`ZorkDialog.ts`), session wiring (`session.ts`), cabinet component + (`ZorkRoot.tsx`). + +## How it fits together + +``` +games/zork1.z3 → ZVM (vendor/zvm.js) → Glk API (vendor/glkapi.js) + → GlkOteReact (display adapter) → ZorkTerminal (React UI) + → ZorkDialog (saves in localStorage, keys: zork...) +``` + +ZVM makes `glk_*` calls into glkapi.js; glkapi drives the display through the +GlkOte protocol, which `GlkOteReact` translates into React state instead of the +stock jQuery-based glkote.js display. + +## Licensing + +See `LICENSE.md` — MIT, scoped to this folder only. The rest of Bitburner is +under its own license (repository root `license.txt`). + +## Updating vendored files + +Re-fetch from upstream and update the versions here and in `LICENSE.md`: + +- `https://registry.npmjs.org/ifvms/-/ifvms-.tgz` → `package/dist/zvm.js` +- `https://github.com/erkyrath/glkote` → `glkapi.js` diff --git a/src/Arcade/Zork/ZorkDialog.ts b/src/Arcade/Zork/ZorkDialog.ts new file mode 100644 index 0000000000..3e281f3973 --- /dev/null +++ b/src/Arcade/Zork/ZorkDialog.ts @@ -0,0 +1,80 @@ +/** + * Minimal non-streaming Glk Dialog implementation backed by localStorage. + * Implements the subset of the GlkOte Dialog contract that vendor/glkapi.js + * uses, persisting Zork save files per game. See src/Arcade/Zork/README.md. + */ +export interface ZorkFileRef { + filename: string; + usage: string; + gameid?: string; +} + +export class ZorkDialog { + /** glkapi checks this to pick the non-streaming file API. */ + readonly streaming = false; + private readonly gameKey: string; + + constructor(gameKey: string) { + this.gameKey = gameKey; + } + + private key(ref: ZorkFileRef): string { + return `zork.${this.gameKey}.${ref.usage}.${ref.filename}`; + } + + file_construct_ref(filename = "", usage = "", gameid = ""): ZorkFileRef { + return { filename, usage, gameid }; + } + + file_construct_temp_ref(usage = ""): ZorkFileRef { + return { filename: `_temp_${usage}`, usage, gameid: "" }; + } + + file_clean_fixed_name(filename: string, __usage: string): string { + return filename.replace(/["/\\<>:|?*]/g, ""); + } + + file_ref_exists(ref: ZorkFileRef): boolean { + return localStorage.getItem(this.key(ref)) !== null; + } + + file_remove_ref(ref: ZorkFileRef): void { + localStorage.removeItem(this.key(ref)); + } + + /** content is an array of byte values (israw) or a string. Returns success. */ + file_write(ref: ZorkFileRef, content: number[] | string, __israw?: boolean): boolean { + try { + const payload = + typeof content === "string" ? { text: content } : { bytes: btoa(String.fromCharCode(...content)) }; + localStorage.setItem(this.key(ref), JSON.stringify(payload)); + return true; + } catch (e) { + console.error("ZorkDialog.file_write failed", e); + return false; + } + } + + file_read(ref: ZorkFileRef, __israw?: boolean): number[] | string | null { + const raw = localStorage.getItem(this.key(ref)); + if (raw === null) return null; + try { + const payload = JSON.parse(raw) as { text?: string; bytes?: string }; + if (typeof payload.text === "string") return payload.text; + if (typeof payload.bytes === "string") { + return Array.from(atob(payload.bytes), (c) => c.charCodeAt(0)); + } + return null; + } catch { + return null; // corrupt entry: treat as missing + } + } + + /** ZVM autosave hooks; unused (we don't enable do_vm_autosave), kept safe. */ + autosave_read(__signature: string): null { + return null; + } + autosave_write(__signature: string, __snapshot: unknown): void { + // no-op + } +} diff --git a/src/Arcade/Zork/ZorkRoot.tsx b/src/Arcade/Zork/ZorkRoot.tsx new file mode 100644 index 0000000000..916bb8f9b5 --- /dev/null +++ b/src/Arcade/Zork/ZorkRoot.tsx @@ -0,0 +1,61 @@ +/** + * A Zork arcade cabinet: loads the bundled story file, runs a ZVM session, + * and renders the terminal. ArcadeRoot supplies the Back button. + */ +import React, { useEffect, useRef, useState } from "react"; +import Typography from "@mui/material/Typography"; +import { AlertEvents } from "../../ui/React/AlertManager"; +import { Settings } from "../../Settings/Settings"; +import { GlkOteReact, TerminalState } from "./GlkOteReact"; +import { ZorkTerminal } from "./ZorkTerminal"; +import { createZorkSession, ZorkSession } from "./session"; +import type { ZorkGame } from "./metadata"; + +export function ZorkRoot({ game }: { game: ZorkGame }): React.ReactElement { + const [state, setState] = useState(null); + const sessionRef = useRef(null); + + useEffect(() => { + let cancelled = false; + fetch(game.url) + .then((response) => { + if (!response.ok) throw new Error(`Failed to load ${game.title}: ${response.status}`); + return response.arrayBuffer(); + }) + .then((buffer) => { + if (cancelled) return; + sessionRef.current = createZorkSession(new Uint8Array(buffer), game.key, setState); + }) + .catch((error) => { + console.error(error); + if (!cancelled) AlertEvents.emit("This machine is broken."); + }); + return () => { + cancelled = true; + sessionRef.current?.dispose(); + sessionRef.current = null; + }; + }, [game]); + + if (!state) { + return The cabinet flickers to life...; + } + const glkote = (): GlkOteReact | undefined => sessionRef.current?.glkote; + return ( + <> + + {game.title} + + glkote()?.sendLine(text)} + onChar={(key) => glkote()?.sendChar(key)} + onFileref={(value) => glkote()?.sendFileref(value)} + /> + + Type "save" / "restore" to keep your progress. It is pitch black. You are likely to be eaten + by a grue. + + + ); +} diff --git a/src/Arcade/Zork/ZorkTerminal.tsx b/src/Arcade/Zork/ZorkTerminal.tsx new file mode 100644 index 0000000000..14f80040a8 --- /dev/null +++ b/src/Arcade/Zork/ZorkTerminal.tsx @@ -0,0 +1,211 @@ +/** + * Retro terminal renderer for the Zork arcade cabinets. + * Pure presentation: renders TerminalState, forwards input upward. + * Colors come from Settings.theme so player themes apply. + */ +import React, { useEffect, useRef, useState } from "react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { Settings } from "../../Settings/Settings"; +import type { StyledRun, TerminalState } from "./GlkOteReact"; + +export interface ZorkTerminalProps { + state: TerminalState; + onLine: (text: string) => void; + onChar: (key: string) => void; + onFileref: (value: { filename: string; usage: string } | null) => void; +} + +const MONO: React.CSSProperties = { + fontFamily: '"Lucida Console", "Consolas", monospace', + fontSize: "14px", + lineHeight: "18px", +}; + +function runStyle(run: StyledRun): React.CSSProperties { + return { + fontStyle: run.style === "emphasized" ? "italic" : undefined, + fontWeight: run.style === "subheader" || run.style === "header" ? "bold" : undefined, + }; +} + +function Line({ runs }: { runs: StyledRun[] }): React.ReactElement { + return ( +
+ {runs.map((run, i) => ( + + {run.text} + + ))} +
+ ); +} + +export function ZorkTerminal({ state, onLine, onChar, onFileref }: ZorkTerminalProps): React.ReactElement { + const [entry, setEntry] = useState(""); + const [slotName, setSlotName] = useState(""); + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const [focused, setFocused] = useState(false); + const scrollRef = useRef(null); + const inputRef = useRef(null); + const slotInputRef = useRef(null); + + function focusGame(): void { + // Don't steal focus while the player is selecting text to copy. + if (window.getSelection()?.toString()) return; + (state.filePrompt ? slotInputRef : inputRef).current?.focus(); + } + + useEffect(() => { + if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + inputRef.current?.focus(); + }, [state]); + + // The game's own prompt (e.g. "> ") is the last buffer line while line input + // is pending; render the input as a continuation of it instead of doubling it. + const lastLine = state.bufferLines[state.bufferLines.length - 1]; + const inlineInput = + !!state.inputRequest && + !state.filePrompt && + !!lastLine && + lastLine + .map((run) => run.text) + .join("") + .trim().length <= 2; + + function keyDown(event: React.KeyboardEvent): void { + // Keep keystrokes inside the cabinet: no Bitburner hotkeys mid-game. + event.stopPropagation(); + if (state.inputRequest?.type === "char") { + event.preventDefault(); + onChar(event.key.length === 1 ? event.key : event.key.toLowerCase()); + return; + } + if (event.key === "Enter") { + const text = entry; + setEntry(""); + setHistory((h) => (text.trim() ? [...h, text] : h)); + setHistoryIndex(-1); + onLine(text); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + const idx = historyIndex < 0 ? history.length - 1 : Math.max(0, historyIndex - 1); + if (history[idx] !== undefined) { + setHistoryIndex(idx); + setEntry(history[idx]); + } + } else if (event.key === "ArrowDown") { + event.preventDefault(); + const idx = historyIndex + 1; + if (idx >= history.length) { + setHistoryIndex(-1); + setEntry(""); + } else { + setHistoryIndex(idx); + setEntry(history[idx]); + } + } + } + + return ( + setFocused(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); + }} + data-focused={focused} + sx={{ + width: "740px", + cursor: "text", + border: `1px solid ${focused ? Settings.theme.primary : Settings.theme.primarydark}`, + boxShadow: focused ? `0 0 8px ${Settings.theme.primary}` : "none", + opacity: focused ? 1 : 0.7, + transition: "opacity 150ms, box-shadow 150ms, border-color 150ms", + backgroundColor: Settings.theme.backgroundprimary, + }} + > + {/* Status line: classic inverse-video Infocom bar */} + + {state.gridLines.length > 0 ? ( + state.gridLines.map((runs, i) => ) + ) : ( +
+ )} + + {/* Story buffer. The game prints its own "> " prompt as the final buffer + line, so when line input is wanted the input field continues that line. */} + + {(inlineInput ? state.bufferLines.slice(0, -1) : state.bufferLines).map((runs, i) => ( + + ))} + {state.error && [ {state.error} ]} + {state.disabled && !state.error && !state.filePrompt && ( + + [ The cabinet hums quietly. Press Back to leave. ] + + )} + {/* Input line */} + {state.inputRequest && !state.filePrompt && ( + + {inlineInput ? ( + + {state.bufferLines[state.bufferLines.length - 1].map((run) => run.text).join("")} + + ) : ( + > + )} + setEntry(e.target.value)} + onKeyDown={keyDown} + variant="standard" + autoFocus + fullWidth + InputProps={{ + disableUnderline: true, + sx: { color: Settings.theme.primary, backgroundColor: "transparent", ...MONO }, + }} + inputProps={{ + maxLength: state.inputRequest.maxlen, + "aria-label": "Zork command input", + style: { padding: 0 }, + }} + /> + + )} + {/* Save/restore slot prompt */} + {state.filePrompt && ( + + + {state.filePrompt.filemode === "read" ? "Restore from slot:" : "Save to slot:"} + + setSlotName(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter" && slotName.trim()) { + onFileref({ filename: slotName.trim(), usage: state.filePrompt?.filetype ?? "save" }); + setSlotName(""); + } + }} + variant="standard" + autoFocus + placeholder="slot name" + InputProps={{ sx: { color: Settings.theme.primary, ...MONO } }} + /> + + + )} + + + ); +} diff --git a/src/Arcade/Zork/games/zork1.z3 b/src/Arcade/Zork/games/zork1.z3 new file mode 100644 index 0000000000..e447402828 Binary files /dev/null and b/src/Arcade/Zork/games/zork1.z3 differ diff --git a/src/Arcade/Zork/games/zork2.z3 b/src/Arcade/Zork/games/zork2.z3 new file mode 100644 index 0000000000..d6dccf0b90 Binary files /dev/null and b/src/Arcade/Zork/games/zork2.z3 differ diff --git a/src/Arcade/Zork/games/zork3.z3 b/src/Arcade/Zork/games/zork3.z3 new file mode 100644 index 0000000000..7ea627eb81 Binary files /dev/null and b/src/Arcade/Zork/games/zork3.z3 differ diff --git a/src/Arcade/Zork/metadata.ts b/src/Arcade/Zork/metadata.ts new file mode 100644 index 0000000000..8d751245df --- /dev/null +++ b/src/Arcade/Zork/metadata.ts @@ -0,0 +1,15 @@ +import zork1Url from "./games/zork1.z3"; +import zork2Url from "./games/zork2.z3"; +import zork3Url from "./games/zork3.z3"; + +export interface ZorkGame { + key: "zork1" | "zork2" | "zork3"; + title: string; + url: string; +} + +export const ZorkGames: ZorkGame[] = [ + { key: "zork1", title: "Zork I: The Great Underground Empire", url: zork1Url }, + { key: "zork2", title: "Zork II: The Wizard of Frobozz", url: zork2Url }, + { key: "zork3", title: "Zork III: The Dungeon Master", url: zork3Url }, +]; diff --git a/src/Arcade/Zork/session.ts b/src/Arcade/Zork/session.ts new file mode 100644 index 0000000000..ba054f9738 --- /dev/null +++ b/src/Arcade/Zork/session.ts @@ -0,0 +1,28 @@ +/** + * Wires a fresh ZVM + glkapi instance to our display adapter and dialog. + * Wiring pattern verified against ifvms 1.1.6 bin/zvm.js: + * vm.prepare(story, options); Glk.init(options); // Glk.init calls vm.init() + */ +import ZVM from "./vendor/zvm.js"; +import { GlkClass } from "./vendor/glkapi.js"; +import { GlkOteReact, TerminalListener } from "./GlkOteReact"; +import { ZorkDialog } from "./ZorkDialog"; + +export interface ZorkSession { + glkote: GlkOteReact; + dispose(): void; +} + +export function createZorkSession(story: Uint8Array, gameKey: string, listener: TerminalListener): ZorkSession { + const dialog = new ZorkDialog(gameKey); + const glkote = new GlkOteReact(dialog, listener); + const vm = new ZVM(); + const Glk = new GlkClass(); + const options = { vm, Glk, GlkOte: glkote, Dialog: dialog }; + vm.prepare(story, options); + Glk.init(options); // synchronously runs the VM to its first input request + return { + glkote, + dispose: () => glkote.detach(), + }; +} diff --git a/src/Arcade/Zork/vendor/glkapi.d.ts b/src/Arcade/Zork/vendor/glkapi.d.ts new file mode 100644 index 0000000000..a975c94370 --- /dev/null +++ b/src/Arcade/Zork/vendor/glkapi.d.ts @@ -0,0 +1,9 @@ +// Type declarations for the verbatim vendored glkapi.js (GlkOte 2.3.7, MIT). +// Describes only the API surface our glue code uses. See LICENSE.md and +// README.md in src/Arcade/Zork. +export interface GlkInstance { + init(options: object): void; +} + +export const Glk: GlkInstance; +export const GlkClass: new () => GlkInstance; diff --git a/src/Arcade/Zork/vendor/glkapi.js b/src/Arcade/Zork/vendor/glkapi.js new file mode 100644 index 0000000000..b0eaab2648 --- /dev/null +++ b/src/Arcade/Zork/vendor/glkapi.js @@ -0,0 +1,6667 @@ +'use strict'; + +/* GlkAPI -- a Javascript Glk API for IF interfaces + * GlkOte Library: version 2.3.7. + * Glk API which this implements: version 0.7.6. + * Designed by Andrew Plotkin + * + * + * This Javascript library is copyright 2010-2025 by Andrew Plotkin. + * It is distributed under the MIT license; see the "LICENSE" file. + * + * This file is a Glk API compatibility layer for glkote.js. It offers a + * set of Javascript calls which closely match the original C Glk API; + * these work by means of glkote.js operations. + * + * This API was built for Quixe, which is a pure-Javascript Glulx + * interpreter. Therefore, the API is a little strange. Notably, it + * accepts text buffers in the form of arrays of integers, not + * Javascript strings. Only the Glk calls that explicitly use strings + * (glk_put_string, etc) accept Javascript native strings. + * + * If you are writing an application in pure Javascript, you can use + * this layer (along with glkote.js). If you are writing a web app which + * is the front face of a server-side Glk app, ignore this file -- use + * glkote.js directly. + */ + +/* Known problems: + + Some places in the library get confused about Unicode characters + beyond 0xFFFF. They are handled correctly by streams, but grid windows + will think they occupy two characters rather than one, which will + throw off the grid spacing. + + Also, the glk_put_jstring() function can't handle them at all. Quixe + printing operations that funnel through glk_put_jstring() -- meaning, + most native string printing -- will break up three-byte characters + into a UTF-16-encoded pair of two-byte characters. This will come + out okay in a buffer window, but it will again mess up grid windows, + and will also double the write-count in a stream. +*/ + +/* All state is contained in GlkClass. */ +var GlkClass = function() { + +var GlkOte = null; /* imported API object */ +var VM = null; /* imported API object (the VM interface) */ +var GiDispa = null; /* imported API object (the dispatch layer) */ +var Blorb = null; /* imported API object (the resource layer) */ + +/* Environment capabilities. (Checked at init time.) */ +var has_canvas; + +/* Options from the vm_options object. */ +var option_exit_warning; +var option_do_vm_autosave; +var option_before_select_hook; +var option_extevent_hook; +var option_glk_gestalt_hook; + +/* Library display state. */ +var has_exited = false; +var ui_disabled = false; +var ui_specialinput = null; +var ui_specialcallback = null; +var event_generation = 0; +var current_partial_inputs = null; +var current_partial_outputs = null; + +/* Initialize the library, initialize the VM, and set it running. (It will + run until the first glk_select() or glk_exit() call.) + + The vm_options argument must have a vm_options.vm field, which must be an + appropriate VM interface object. (For example, Quixe.) This must have + init() and resume() methods. + + The vm_options argument is also passed through to GlkOte as the game + interface object. It can be used to affect some GlkOte display options, + such as window spacing. + + (You do not need to provide a vm_options.accept() function. The Glk + library sets that up for you.) +*/ +function init(vm_options) { + /* Either GlkOte was passed in or we must create one. */ + if (vm_options.GlkOte) { + GlkOte = vm_options.GlkOte; + } + else if (window.GlkOteClass) { + GlkOte = new window.GlkOteClass(); + } + + /* Either Blorb was passed in or we don't have one. */ + if (vm_options.Blorb) { + Blorb = vm_options.Blorb; + } + + /* Check for canvas support. We don't rely on jquery here. */ + has_canvas = (document.createElement('canvas').getContext != undefined); + + VM = vm_options.vm; + GiDispa = vm_options.GiDispa; /* may be null/undefined */ + + vm_options.accept = accept_ui_event; + + option_exit_warning = vm_options.exit_warning; + option_do_vm_autosave = vm_options.do_vm_autosave; + option_before_select_hook = vm_options.before_select_hook; + option_extevent_hook = vm_options.extevent_hook; + option_glk_gestalt_hook = vm_options.glk_gestalt_hook; + + if (option_before_select_hook) { + option_before_select_hook(); + } + + /* Initialize the lower levels. */ + + if (GiDispa) + GiDispa.init({ io:vm_options.io, vm:vm_options.vm }); + GlkOte.init(vm_options); +} + +function is_inited() { + return (VM != null && GlkOte != null); +} + +function accept_ui_event(obj) { + var box; + + //qlog("### accept_ui_event: " + obj.type + ", gen " + obj.gen); + if (ui_disabled) { + /* We've hit glk_exit() or a VM fatal error, or just blocked the UI for + some modal dialog. */ + qlog("### ui is disabled, ignoring event"); + return; + } + + if (obj.gen != event_generation) { + GlkOte.log('Input event had wrong generation number: got ' + obj.gen + ', currently at ' + event_generation); + return; + } + event_generation += 1; + + /* Note any partial inputs; we'll need them if the game cancels a line + input. This may be undef. */ + current_partial_inputs = obj.partial; + + switch (obj.type) { + case 'init': + content_metrics = complete_metrics(obj.metrics); + /* We ignore the support array. This library is updated in sync + with GlkOte, so we know what it supports. */ + VM.start(); + break; + + case 'external': + var res = null; + if (option_extevent_hook) { + res = option_extevent_hook(obj.value); + } + if (!res && obj.value == 'timer') { + /* Timer events no longer come in this way, but we'll still + accept them. */ + gli_timer_started = Date.now(); + res = { type: Const.evtype_Timer }; + } + if (res && res.type) { + handle_external_input(res); + } + break; + + case 'timer': + gli_timer_started = Date.now(); + var res = { type: Const.evtype_Timer }; + handle_external_input(res); + break; + + case 'hyperlink': + handle_hyperlink_input(obj.window, obj.value); + break; + + case 'mouse': + handle_mouse_input(obj.window, obj.x, obj.y); + break; + + case 'char': + handle_char_input(obj.window, obj.value); + break; + + case 'line': + handle_line_input(obj.window, obj.value, obj.terminator); + break; + + case 'arrange': + content_metrics = complete_metrics(obj.metrics); + box = { + left: content_metrics.outspacingx, + top: content_metrics.outspacingy, + right: content_metrics.width-content_metrics.outspacingx, + bottom: content_metrics.height-content_metrics.outspacingy + }; + if (gli_rootwin) + gli_window_rearrange(gli_rootwin, box); + handle_arrange_input(); + break; + + case 'redraw': + handle_redraw_input(); + break; + + case 'specialresponse': + if (obj.response == 'fileref_prompt') { + gli_fileref_create_by_prompt_callback(obj); + } + break; + } +} + +/* Given a partial metrics object, return one with all the required + values. Missing values will default to 0 or the standard inherited + terms. (E.g., if "inspacingx" is missing it will default to + "inspacing", then "spacing", then 0. See measure_window() in + glkote.js or data_metrics_parse() in RemGlk.) + + All values in the given object will be copied over; defaulting only + applies to missing values from the required set. +*/ +function complete_metrics(metrics) { + + // Default values if absolutely nothing is specified. + var res = { + width: 80, + height: 50, + + gridcharwidth: 1, + gridcharheight: 1, + buffercharwidth: 1, + buffercharheight: 1, + + gridmarginx: 0, + gridmarginy: 0, + buffermarginx: 0, + buffermarginy: 0, + graphicsmarginx: 0, + graphicsmarginy: 0, + + outspacingx: 0, + outspacingy: 0, + inspacingx: 0, + inspacingy: 0, + }; + + // Various ways of specifying defaults. + var val; + + val = metrics.charwidth; + if (val !== undefined) { + res.gridcharwidth = val; + res.buffercharwidth = val; + } + val = metrics.charheight; + if (val !== undefined) { + res.gridcharheight = val; + res.buffercharheight = val; + } + + val = metrics.margin; + if (val !== undefined) { + res.gridmarginx = val; + res.gridmarginy = val; + res.buffermarginx = val; + res.buffermarginy = val; + res.graphicsmarginx = val; + res.graphicsmarginy = val; + } + + val = metrics.gridmargin; + if (val !== undefined) { + res.gridmarginx = val; + res.gridmarginy = val; + } + + val = metrics.buffermargin; + if (val !== undefined) { + res.buffermarginx = val; + res.buffermarginy = val; + } + + val = metrics.graphicsmargin; + if (val !== undefined) { + res.graphicsmarginx = val; + res.graphicsmarginy = val; + } + + val = metrics.marginx; + if (val !== undefined) { + res.gridmarginx = val; + res.buffermarginx = val; + res.graphicsmarginx = val; + } + + val = metrics.marginy; + if (val !== undefined) { + res.gridmarginy = val; + res.buffermarginy = val; + res.graphicsmarginy = val; + } + + val = metrics.spacing; + if (val !== undefined) { + res.inspacingx = val; + res.inspacingy = val; + res.outspacingx = val; + res.outspacingy = val; + } + + val = metrics.inspacing; + if (val !== undefined) { + res.inspacingx = val; + res.inspacingy = val; + } + + val = metrics.outspacing; + if (val !== undefined) { + res.outspacingx = val; + res.outspacingy = val; + } + + val = metrics.spacingx; + if (val !== undefined) { + res.inspacingx = val; + res.outspacingx = val; + } + + val = metrics.spacingy; + if (val !== undefined) { + res.inspacingy = val; + res.outspacingy = val; + } + + // Copy over all the supplied fields. These override the defaults above. + res = Object.assign(res, metrics); + + return res; +} + +function handle_arrange_input() { + if (!gli_selectref) + return; + + gli_selectref.set_field(0, Const.evtype_Arrange); + gli_selectref.set_field(1, null); + gli_selectref.set_field(2, 0); + gli_selectref.set_field(3, 0); + + if (GiDispa) + GiDispa.prepare_resume(gli_selectref); + gli_selectref = null; + VM.resume(); +} + +function handle_redraw_input() { + if (!gli_selectref) + return; + + gli_selectref.set_field(0, Const.evtype_Redraw); + gli_selectref.set_field(1, null); + gli_selectref.set_field(2, 0); + gli_selectref.set_field(3, 0); + + if (GiDispa) + GiDispa.prepare_resume(gli_selectref); + gli_selectref = null; + VM.resume(); +} + +function handle_external_input(res) { + if (!gli_selectref) + return; + + /* This also handles timer input. */ + var val1 = 0; + var val2 = 0; + if (res.val1) + val1 = res.val1; + if (res.val2) + val2 = res.val2; + + gli_selectref.set_field(0, res.type); + gli_selectref.set_field(1, null); + gli_selectref.set_field(2, val1); + gli_selectref.set_field(3, val2); + + if (GiDispa) + GiDispa.prepare_resume(gli_selectref); + gli_selectref = null; + VM.resume(); +} + +function handle_hyperlink_input(disprock, val) { + if (!gli_selectref) + return; + + var win = null; + for (win=gli_windowlist; win; win=win.next) { + if (win.disprock == disprock) + break; + } + if (!win || !win.hyperlink_request) + return; + + gli_selectref.set_field(0, Const.evtype_Hyperlink); + gli_selectref.set_field(1, win); + gli_selectref.set_field(2, val); + gli_selectref.set_field(3, 0); + + win.hyperlink_request = false; + + if (GiDispa) + GiDispa.prepare_resume(gli_selectref); + gli_selectref = null; + VM.resume(); +} + +function handle_mouse_input(disprock, xpos, ypos) { + if (!gli_selectref) + return; + + var win = null; + for (win=gli_windowlist; win; win=win.next) { + if (win.disprock == disprock) + break; + } + if (!win || !win.mouse_request) + return; + + gli_selectref.set_field(0, Const.evtype_MouseInput); + gli_selectref.set_field(1, win); + gli_selectref.set_field(2, xpos); + gli_selectref.set_field(3, ypos); + + win.mouse_request = false; + + if (GiDispa) + GiDispa.prepare_resume(gli_selectref); + gli_selectref = null; + VM.resume(); +} + +function handle_char_input(disprock, input) { + var charval; + + if (!gli_selectref) + return; + + var win = null; + for (win=gli_windowlist; win; win=win.next) { + if (win.disprock == disprock) + break; + } + if (!win || !win.char_request) + return; + + if (input.length == 1) { + charval = input.charCodeAt(0); + if (charval >= 0x100 && !win.char_request_uni) + charval = Const.keycode_Unknown; + } + else { + charval = KeystrokeNameMap[input]; + if (!charval) + charval = Const.keycode_Unknown; + } + + gli_selectref.set_field(0, Const.evtype_CharInput); + gli_selectref.set_field(1, win); + gli_selectref.set_field(2, charval); + gli_selectref.set_field(3, 0); + + win.char_request = false; + win.char_request_uni = false; + win.input_generation = null; + + if (GiDispa) + GiDispa.prepare_resume(gli_selectref); + gli_selectref = null; + VM.resume(); +} + +function handle_line_input(disprock, input, termkey) { + var ix; + + if (!gli_selectref) + return; + + var win = null; + for (win=gli_windowlist; win; win=win.next) { + if (win.disprock == disprock) + break; + } + if (!win || !win.line_request) + return; + + if (input.length > win.linebuf.length) + input = input.slice(0, win.linebuf.length); + + if (win.request_echo_line_input) { + ix = win.style; + gli_set_style(win.str, Const.style_Input); + gli_window_put_string(win, input); + if (win.echostr) + glk_put_jstring_stream(win.echostr, input); + gli_set_style(win.str, ix); + gli_window_put_string(win, "\n"); + if (win.echostr) + glk_put_jstring_stream(win.echostr, "\n"); + } + + for (ix=0; ix 100) { + win.reserve.splice(0, win.reserve.length-100); + } + break; + case Const.wintype_TextGrid: + if (win.gridwidth == 0 || win.gridheight == 0) + break; + obj.lines = []; + for (ix=0; ix= 0) { + /* We're going to delete every command before the + fill, except that we save the last setcolor. */ + var setcol = null; + for (ix=0; ix=0; ix--) { + var obj = res.windows[ix]; + var win = { + type: obj.type, rock: obj.rock, disprock: obj.disprock, + style: obj.style, hyperlink: obj.hyperlink + }; + GiDispa.class_register('window', win, win.disprock); + + win.prev = null; + win.next = gli_windowlist; + gli_windowlist = win; + if (win.next) + win.next.prev = win; + } + + for (var ix=res.streams.length-1; ix>=0; ix--) { + var obj = res.streams[ix]; + var str = { + type: obj.type, rock: obj.rock, disprock: obj.disprock, + unicode: obj.unicode, isbinary: obj.isbinary, + readcount: obj.readcount, writecount: obj.writecount, + readable: obj.readable, writable: obj.writable, + streaming: obj.streaming + }; + GiDispa.class_register('stream', str, str.disprock); + + str.prev = null; + str.next = gli_streamlist; + gli_streamlist = str; + if (str.next) + str.next.prev = str; + } + + for (var ix=res.filerefs.length-1; ix>=0; ix--) { + var obj = res.filerefs[ix]; + var fref = { + type: obj.type, rock: obj.rock, disprock: obj.disprock, + filename: obj.filename, textmode: obj.textmode, + filetype: obj.filetype, filetypename: obj.filetypename + }; + GiDispa.class_register('fileref', fref, fref.disprock); + + fref.prev = null; + fref.next = gli_filereflist; + gli_filereflist = fref; + if (fref.next) + fref.next.prev = fref; + } + + /* ...Now we fill in the cross-references. */ + + for (var ix=0; ix str.bufeof) + str.bufpos = str.bufeof; + } + else { + str.ref = obj.ref; + str.fstream = Dialog.file_fopen(str.origfmode, str.ref); + if (!str.fstream) { + /* This is the panic case. We can't reopen the stream, + but the game expects an open stream! We'll just + have to open a temporary file; the user will never + get their data, but at least the game won't crash. + (Better policy would be to prompt the user for + a new file location...) */ + var tempref = Dialog.file_construct_temp_ref(str.ref.usage); + str.fstream = Dialog.file_fopen(str.origfmode, tempref); + if (!str.fstream) + throw('restore_allstate: could not reopen even a temp stream for: ' + str.ref.filename); + } + + if (str.origfmode != Const.filemode_WriteAppend) { + /* Jump to the last known filepos. */ + str.fstream.fseek(obj.filepos, Const.seekmode_Start); + } + + str.buffer4 = str.fstream.BufferClass.alloc(4); + } + break; + + } + } + + for (var ix=0; ix> 10), 0xDC00 + (val & 0x3FF)); + } +} + +/* Given an array, return an array of the same length with all the values + trimmed to the range 0-255. This may be the same array. */ +function TrimArrayToBytes(arr) { + var ix, newarr; + var len = arr.length; + for (ix=0; ix= 0x100) + break; + } + if (ix == len) { + return arr; + } + newarr = Array(len); + for (ix=0; ix= 0x100) + newarr[ix] = 63; // '?' + else + newarr[ix] = arr[ix]; + } + return newarr; +} + +/* Convert an array of 8-bit values to a JS string, trimming if + necessary. */ +function ByteArrayToString(arr) { + var ix, newarr; + var len = arr.length; + if (len == 0) + return ''; + for (ix=0; ix= 0x100) + break; + } + if (ix == len) { + return String.fromCharCode.apply(this, arr); + } + newarr = Array(len); + for (ix=0; ix= 0x10000) + break; + } + if (ix == len) { + return String.fromCharCode.apply(this, arr); + } + newarr = Array(len); + for (ix=0; ix> 10), 0xDC00 + (val & 0x3FF)); + } + } + return newarr.join(''); +} + +/* Convert an array of 32-bit Unicode values to an array of 8-bit byte + values, encoded UTF-8. If all the values are 0-127, this returns the + same array. Otherwise it returns a new (longer) array. */ +function UniArrayToUTF8(arr) { + var count = 0; + + for (var ix=0; ix> 6)); + res.push(0x80 | (val & 0x03F) ); + } + else if (val < 0x10000) { + res.push(0xE0 | ((val & 0xF000) >> 12)); + res.push(0x80 | ((val & 0x0FC0) >> 6)); + res.push(0x80 | (val & 0x003F) ); + } + else if (val < 0x200000) { + res.push(0xF0 | ((val & 0x1C0000) >> 18)); + res.push(0x80 | ((val & 0x03F000) >> 12)); + res.push(0x80 | ((val & 0x000FC0) >> 6)); + res.push(0x80 | (val & 0x00003F) ); + } + else { + res.push(63); // '?' + } + } + + return res; +} + +/* Convert an array of 32-bit Unicode values to an array of 8-bit byte + values, encoded as big-endian words. */ +function UniArrayToBE32(arr) { + var res = new Array(4*arr.length); + for (var ix=0; ix> 24) & 0xFF; + res[4*ix+1] = (val >> 16) & 0xFF; + res[4*ix+2] = (val >> 8) & 0xFF; + res[4*ix+3] = (val) & 0xFF; + } + return res; +} + +/* Log the message in the browser's error log, if it has one. (This shows + up in Safari, in Opera, and in Firefox if you have Firebug installed.) +*/ +function qlog(msg) { + if (window.console && console.log) + console.log(msg); + else if (window.opera && opera.postError) + opera.postError(msg); +} + +/* RefBox: Simple class used for "call-by-reference" Glk arguments. The object + is just a box containing a single value, which can be written and read. +*/ +function RefBox() { + this.value = undefined; + this.set_value = function(val) { + this.value = val; + } + this.get_value = function() { + return this.value; + } +} + +/* RefStruct: Used for struct-type Glk arguments. After creating the + object, you should call push_field() the appropriate number of times, + to set the initial field values. Then set_field() can be used to + change them, and get_fields() retrieves the list of all fields. + + (The usage here is loose, since Javascript is forgiving about arrays. + Really the caller could call set_field() instead of push_field() -- + or skip that step entirely, as long as the Glk function later calls + set_field() for each field. Which it should.) +*/ +function RefStruct(numels) { + this.fields = []; + this.push_field = function(val) { + this.fields.push(val); + } + this.set_field = function(pos, val) { + this.fields[pos] = val; + } + this.get_field = function(pos) { + return this.fields[pos]; + } + this.get_fields = function() { + return this.fields; + } +} + +/* Dummy return value, which means that the Glk call is still in progress, + or will never return at all. This is used by glk_exit(), glk_select(), + and glk_fileref_create_by_prompt(). +*/ +var DidNotReturn = { dummy: 'Glk call has not yet returned' }; + +/* This returns a hint for whether the Glk call (by selector number) + might block or never return. True for glk_exit(), glk_select(), + and glk_fileref_create_by_prompt(). +*/ +function call_may_not_return(id) { + if (id == 0x001 || id == 0x0C0 || id == 0x062) + return true; + else + return false; +} + +var strtype_File = 1; +var strtype_Window = 2; +var strtype_Memory = 3; +var strtype_Resource = 4; + +/* Extra update information -- autorestore only. */ +var gli_autorestore_glkstate = null; + +/* Beginning of linked list of windows. */ +var gli_windowlist = null; +var gli_rootwin = null; +/* Set when any window is created, destroyed, or resized. */ +var geometry_changed = true; +/* Received from GlkOte; describes the window size. */ +var content_metrics = null; + +/* Beginning of linked list of streams. */ +var gli_streamlist = null; +/* Beginning of linked list of filerefs. */ +var gli_filereflist = null; +/* Beginning of linked list of schannels. */ +var gli_schannellist = null; + +/* The current output stream. */ +var gli_currentstr = null; + +/* During a glk_select() block, this is the RefStruct which will contain + the result. */ +var gli_selectref = null; + +/* This is used to assigned disprock values to windows, when there is + no GiDispa layer to provide them. */ +var gli_api_display_rocks = 1; + +/* A positive number if the timer is set. */ +var gli_timer_interval = null; +var gli_timer_started = null; /* when the setTimeout began */ +var gli_timer_lastsent = null; /* last interval sent to GlkOte */ + +function gli_new_window(type, rock) { + var win = {}; + win.type = type; + win.rock = rock; + win.disprock = undefined; + + win.parent = null; + win.str = gli_stream_open_window(win); + win.echostr = null; + win.style = Const.style_Normal; + win.hyperlink = 0; + + win.input_generation = null; + win.linebuf = null; + win.char_request = false; + win.line_request = false; + win.char_request_uni = false; + win.line_request_uni = false; + win.hyperlink_request = false; + win.mouse_request = false; + win.echo_line_input = true; + win.line_input_terminators = []; + win.request_echo_line_input = null; /* only used during a request */ + + /* window-type-specific info is set up in glk_window_open */ + + win.prev = null; + win.next = gli_windowlist; + gli_windowlist = win; + if (win.next) + win.next.prev = win; + + if (GiDispa) + GiDispa.class_register('window', win); + else + win.disprock = gli_api_display_rocks++; + /* We need to assign a disprock even if there's no GiDispa layer, + because GlkOte differentiates windows by their disprock. */ + geometry_changed = true; + + return win; +} + +function gli_delete_window(win) { + var prev, next; + + if (GiDispa) + GiDispa.class_unregister('window', win); + geometry_changed = true; + + win.echostr = null; + if (win.str) { + gli_delete_stream(win.str); + win.str = null; + } + + prev = win.prev; + next = win.next; + win.prev = null; + win.next = null; + + if (prev) + prev.next = next; + else + gli_windowlist = next; + if (next) + next.prev = prev; + + win.parent = null; + win.rock = null; + win.disprock = null; +} + +function gli_windows_unechostream(str) { + var win; + + for (win=gli_windowlist; win; win=win.next) { + if (win.echostr === str) + win.echostr = null; + } +} + +/* Add a (Javascript) string to the given window's display. */ +function gli_window_put_string(win, val) { + var ix, ch; + + //### might be efficient to split the implementation up into + //### gli_window_buffer_put_string(), etc, since many functions + //### know the window type when they call this + switch (win.type) { + case Const.wintype_TextBuffer: + if (win.style != win.accumstyle + || win.hyperlink != win.accumhyperlink) + gli_window_buffer_deaccumulate(win); + win.accum.push(val); + break; + case Const.wintype_TextGrid: + for (ix=0; ix= win.gridwidth) { + win.cursorx = 0; + win.cursory++; + } + if (win.cursory < 0) + win.cursory = 0; + if (win.cursory >= win.gridheight) + break; /* outside the window */ + + if (ch == "\n") { + /* a newline just moves the cursor. */ + win.cursory++; + win.cursorx = 0; + continue; + } + + var lineobj = win.lines[win.cursory]; + lineobj.dirty = true; + lineobj.chars[win.cursorx] = ch; + lineobj.styles[win.cursorx] = win.style; + lineobj.hyperlinks[win.cursorx] = win.hyperlink; + + win.cursorx++; + /* We can leave the cursor outside the window, since it will be + canonicalized next time a character is printed. */ + } + break; + } +} + +/* Canonicalize the cursor position. That is, the cursor may have + been left outside the window area; wrap it if necessary. + + Returns true if the cursor winds up wrapped outside the window entirely; + false if the cursor winds up at a legal printing position. +*/ +function gli_window_grid_canonicalize(win) { + if (win.cursorx < 0) + win.cursorx = 0; + if (win.cursorx >= win.gridwidth) { + win.cursorx = 0; + win.cursory++; + } + if (win.cursory < 0) + win.cursory = 0; + if (win.cursory >= win.gridheight) + return true; /* outside the window */ + return false; +} + +/* Take the accumulation of strings (since the last style change) and + assemble them into a buffer window update. This must be called + after each style change; it must also be called right before + GlkOte.update(). (Actually we call it right before win.accum.push + if the style has changed -- there's no need to call for *every* style + change if no text is being pushed out in between.) +*/ +function gli_window_buffer_deaccumulate(win) { + var conta = win.content; + var stylename = StyleNameMap[win.accumstyle]; + var text, ls, ix, obj, arr; + + if (win.accum.length) { + text = win.accum.join(''); + ls = text.split('\n'); + for (ix=0; ix win.gridheight) { + win.lines.length = win.gridheight; + } + else if (oldheight < win.gridheight) { + for (ix=oldheight; ix win.gridwidth) { + lineobj.dirty = true; + lineobj.chars.length = win.gridwidth; + lineobj.styles.length = win.gridwidth; + lineobj.hyperlinks.length = win.gridwidth; + } + else if (oldwidth < win.gridwidth) { + lineobj.dirty = true; + for (cx=oldwidth; cx= max) { + split = min; + } + else { + split = Math.min(Math.max(split, min), max-splitwid); + } + + win.pair_splitpos = split; + win.pair_splitwidth = splitwid; + if (win.pair_vertical) { + box1 = { + left: win.bbox.left, + right: win.pair_splitpos, + top: win.bbox.top, + bottom: win.bbox.bottom + }; + box2 = { + left: box1.right + win.pair_splitwidth, + right: win.bbox.right, + top: win.bbox.top, + bottom: win.bbox.bottom + }; + } + else { + box1 = { + top: win.bbox.top, + bottom: win.pair_splitpos, + left: win.bbox.left, + right: win.bbox.right + }; + box2 = { + top: box1.bottom + win.pair_splitwidth, + bottom: win.bbox.bottom, + left: win.bbox.left, + right: win.bbox.right + }; + } + if (!win.pair_backward) { + ch1 = win.child1; + ch2 = win.child2; + } + else { + ch1 = win.child2; + ch2 = win.child1; + } + + gli_window_rearrange(ch1, box1); + gli_window_rearrange(ch2, box2); + break; + + } +} + +function gli_new_stream(type, readable, writable, rock) { + var str = {}; + str.type = type; + str.rock = rock; + str.disprock = undefined; + + str.unicode = false; + /* isbinary is only meaningful for Resource and streaming-File streams */ + str.isbinary = false; + str.streaming = false; + str.ref = null; + str.win = null; + str.file = null; + + /* for buffer mode */ + str.buf = null; + str.bufpos = 0; + str.buflen = 0; + str.bufeof = 0; + str.timer_id = null; + str.flush_func = null; + + /* for streaming mode */ + str.fstream = null; + + str.readcount = 0; + str.writecount = 0; + str.readable = readable; + str.writable = writable; + + str.prev = null; + str.next = gli_streamlist; + gli_streamlist = str; + if (str.next) + str.next.prev = str; + + if (GiDispa) + GiDispa.class_register('stream', str); + + return str; +} + +function gli_delete_stream(str) { + var prev, next; + + if (str === gli_currentstr) { + gli_currentstr = null; + } + + gli_windows_unechostream(str); + + if (str.type == strtype_Memory) { + if (GiDispa) + GiDispa.unretain_array(str.buf); + } + else if (str.type == strtype_File) { + if (str.fstream) { + str.fstream.fclose(); + str.fstream = null; + } + } + + if (GiDispa) + GiDispa.class_unregister('stream', str); + + prev = str.prev; + next = str.next; + str.prev = null; + str.next = null; + + if (prev) + prev.next = next; + else + gli_streamlist = next; + if (next) + next.prev = prev; + + str.fstream = null; + str.buf = null; + str.readable = false; + str.writable = false; + str.ref = null; + str.win = null; + str.file = null; + str.rock = null; + str.disprock = null; +} + +function gli_stream_open_window(win) { + var str; + str = gli_new_stream(strtype_Window, false, true, 0); + str.unicode = true; + str.win = win; + return str; +} + +/* This is called on every write to a file stream. If a file is being + written intermittently (a transcript file, for example) we'd like to + flush the output every few seconds, in case the user closes the + browser without closing the file ("script off"). + + We do this by setting a ten-second timer (if there isn't one set already). + The timer calls a flush method on the stream. + + (If autosave is on, we'll wind up flushing on most glk_select calls, + which isn't quite as nicely paced. But it's a minor problem.) +*/ +function gli_stream_dirty_file(str) { + if (str.streaming) + GlkOte.log('Bug: gli_stream_dirty_file called for streaming file!'); + if (str.timer_id === null) { + if (str.flush_func === null) { + /* Bodge together a closure to act as a stream method. */ + str.flush_func = function() { gli_stream_flush_file(str); }; + } + str.timer_id = setTimeout(str.flush_func, 10000); + } +} + +/* Write out the contents of a file stream to the "disk file". Because + localStorage doesn't support appending, we have to dump the entire + buffer out. +*/ +function gli_stream_flush_file(str) { + var Dialog = GlkOte.getlibrary('Dialog'); + + if (str.streaming) + GlkOte.log('Bug: gli_stream_flush_file called for streaming file!'); + if (!(str.timer_id === null)) { + clearTimeout(str.timer_id); + } + str.timer_id = null; + Dialog.file_write(str.ref, str.buf); +} + +function gli_new_fileref(filename, usage, rock, ref) { + var Dialog = GlkOte.getlibrary('Dialog'); + + var fref = {}; + fref.filename = filename; + fref.rock = rock; + fref.disprock = undefined; + + fref.textmode = ((usage & Const.fileusage_TextMode) != 0); + fref.filetype = (usage & Const.fileusage_TypeMask); + fref.filetypename = FileTypeMap[fref.filetype]; + if (!fref.filetypename) { + fref.filetypename = 'xxx'; + } + + if (!ref) { + var gameid = ''; + if (fref.filetype == Const.fileusage_SavedGame) + gameid = VM.get_signature(); + ref = Dialog.file_construct_ref(fref.filename, fref.filetypename, gameid); + } + fref.ref = ref; + + fref.prev = null; + fref.next = gli_filereflist; + gli_filereflist = fref; + if (fref.next) + fref.next.prev = fref; + + if (GiDispa) + GiDispa.class_register('fileref', fref); + + return fref; +} + +function gli_delete_fileref(fref) { + var prev, next; + + if (GiDispa) + GiDispa.class_unregister('fileref', fref); + + prev = fref.prev; + next = fref.next; + fref.prev = null; + fref.next = null; + + if (prev) + prev.next = next; + else + gli_filereflist = next; + if (next) + next.prev = prev; + + fref.filename = null; + fref.ref = null; + fref.rock = null; + fref.disprock = null; +} + +/* Write one character (given as a Unicode value) to a stream. + This is called by both the one-byte and four-byte character APIs. +*/ +function gli_put_char(str, ch) { + if (!str || !str.writable) + throw('gli_put_char: invalid stream'); + + if (!str.unicode) { + if (ch < 0 || ch >= 0x100) + ch = 63; // '?' + } + + str.writecount += 1; + + switch (str.type) { + case strtype_File: + if (str.streaming) { + if (!str.unicode) { + str.buffer4[0] = ch; + str.fstream.fwrite(str.buffer4, 1); + } + else { + if (!str.isbinary) { + /* cheap UTF-8 stream */ + var len; + if (ch < 0x10000) { + len = str.buffer4.write(String.fromCharCode(ch)); + str.fstream.fwrite(str.buffer4, len); // utf8 + } + else { + /* String.fromCharCode chokes on astral characters; + do it the hard way */ + var arr8 = UniArrayToUTF8([ch]); + var buf = str.fstream.BufferClass.from(arr8); + str.fstream.fwrite(buf); + } + } + else { + /* cheap big-endian stream */ + str.buffer4.writeUInt32BE(ch, 0, true); + str.fstream.fwrite(str.buffer4, 4); + } + } + } + else { + /* non-streaming... */ + gli_stream_dirty_file(str); + if (!str.unicode || (ch < 0x80 && !str.isbinary)) { + if (str.bufpos < str.buflen) { + str.buf[str.bufpos] = ch; + str.bufpos += 1; + if (str.bufpos > str.bufeof) + str.bufeof = str.bufpos; + } + } + else { + var arr; + if (!str.isbinary) + arr = UniArrayToUTF8([ch]); + else + arr = UniArrayToBE32([ch]); + var len = arr.length; + if (len > str.buflen-str.bufpos) + len = str.buflen-str.bufpos; + for (var ix=0; ix str.bufeof) + str.bufeof = str.bufpos; + } + } + break; + case strtype_Memory: + if (str.bufpos < str.buflen) { + str.buf[str.bufpos] = ch; + str.bufpos += 1; + if (str.bufpos > str.bufeof) + str.bufeof = str.bufpos; + } + break; + case strtype_Window: + if (str.win.line_request) + throw('gli_put_char: window has pending line request'); + gli_window_put_string(str.win, CharToString(ch)); + if (str.win.echostr) + gli_put_char(str.win.echostr, ch); + break; + } +} + +/* Write characters (given as an array of Unicode values) to a stream. + This is called by both the one-byte and four-byte character APIs. + The "allbytes" argument is a hint that all the array values are + already in the range 0-255. +*/ +function gli_put_array(str, arr, allbytes) { + var ix, len, val; + + if (!str || !str.writable) + throw('gli_put_array: invalid stream'); + + if (!str.unicode && !allbytes) { + arr = TrimArrayToBytes(arr); + allbytes = true; + } + + str.writecount += arr.length; + + switch (str.type) { + case strtype_File: + if (str.streaming) { + if (!str.unicode) { + var buf = str.fstream.BufferClass.from(arr); + str.fstream.fwrite(buf); + } + else { + if (!str.isbinary) { + /* cheap UTF-8 stream */ + var arr8 = UniArrayToUTF8(arr); + var buf = str.fstream.BufferClass.from(arr8); + str.fstream.fwrite(buf); + } + else { + /* cheap big-endian stream */ + var buf = str.fstream.BufferClass.alloc(4*arr.length); + for (ix=0; ix str.buflen-str.bufpos) + len = str.buflen-str.bufpos; + for (ix=0; ix str.bufeof) + str.bufeof = str.bufpos; + } + break; + case strtype_Memory: + len = arr.length; + if (len > str.buflen-str.bufpos) + len = str.buflen-str.bufpos; + for (ix=0; ix str.bufeof) + str.bufeof = str.bufpos; + break; + case strtype_Window: + if (str.win.line_request) + throw('gli_put_array: window has pending line request'); + if (allbytes) + val = String.fromCharCode.apply(this, arr); + else + val = UniArrayToString(arr); + gli_window_put_string(str.win, val); + if (str.win.echostr) + gli_put_array(str.win.echostr, arr, allbytes); + break; + } +} + +function gli_get_char(str, want_unicode) { + var ch; + + if (!str || !str.readable) + return -1; + + switch (str.type) { + case strtype_File: + if (str.streaming) { + if (!str.unicode) { + var len = str.fstream.fread(str.buffer4, 1); + if (!len) + return -1; + str.readcount++; + return str.buffer4[0]; + } + else { + if (!str.isbinary) { + /* slightly less cheap UTF8 stream */ + var val0, val1, val2, val3; + var len = str.fstream.fread(str.buffer4, 1); + if (!len) + return -1; + val0 = str.buffer4[0]; + if (val0 < 0x80) { + ch = val0; + } + else { + var len = str.fstream.fread(str.buffer4, 1); + if (!len) + return -1; + val1 = str.buffer4[0]; + if ((val1 & 0xC0) != 0x80) + return -1; + if ((val0 & 0xE0) == 0xC0) { + ch = (val0 & 0x1F) << 6; + ch |= (val1 & 0x3F); + } + else { + var len = str.fstream.fread(str.buffer4, 1); + if (!len) + return -1; + val2 = str.buffer4[0]; + if ((val2 & 0xC0) != 0x80) + return -1; + if ((val0 & 0xF0) == 0xE0) { + ch = (((val0 & 0xF)<<12) & 0x0000F000); + ch |= (((val1 & 0x3F)<<6) & 0x00000FC0); + ch |= (((val2 & 0x3F)) & 0x0000003F); + } + else if ((val0 & 0xF0) == 0xF0) { + var len = str.fstream.fread(str.buffer4, 1); + if (!len) + return -1; + val3 = str.buffer4[0]; + if ((val3 & 0xC0) != 0x80) + return -1; + ch = (((val0 & 0x7)<<18) & 0x1C0000); + ch |= (((val1 & 0x3F)<<12) & 0x03F000); + ch |= (((val2 & 0x3F)<<6) & 0x000FC0); + ch |= (((val3 & 0x3F)) & 0x00003F); + } + else { + return -1; + } + } + } + } + else { + /* cheap big-endian stream */ + var len = str.fstream.fread(str.buffer4, 4); + if (len < 4) + return -1; + /*### or buf.readUInt32BE(0, true) */ + ch = (str.buffer4[0] << 24); + ch |= (str.buffer4[1] << 16); + ch |= (str.buffer4[2] << 8); + ch |= str.buffer4[3]; + } + str.readcount++; + ch >>>= 0; + if (!want_unicode && ch >= 0x100) + return 63; // return '?' + return ch; + } + } + /* non-streaming, fall through to resource... */ + case strtype_Resource: + if (str.unicode) { + if (str.isbinary) { + /* cheap big-endian stream */ + if (str.bufpos >= str.bufeof) + return -1; + ch = str.buf[str.bufpos]; + str.bufpos++; + if (str.bufpos >= str.bufeof) + return -1; + ch = (ch << 8) | (str.buf[str.bufpos] & 0xFF); + str.bufpos++; + if (str.bufpos >= str.bufeof) + return -1; + ch = (ch << 8) | (str.buf[str.bufpos] & 0xFF); + str.bufpos++; + if (str.bufpos >= str.bufeof) + return -1; + ch = (ch << 8) | (str.buf[str.bufpos] & 0xFF); + str.bufpos++; + } + else { + /* slightly less cheap UTF8 stream */ + var val0, val1, val2, val3; + if (str.bufpos >= str.bufeof) + return -1; + val0 = str.buf[str.bufpos]; + str.bufpos++; + if (val0 < 0x80) { + ch = val0; + } + else { + if (str.bufpos >= str.bufeof) + return -1; + val1 = str.buf[str.bufpos]; + str.bufpos++; + if ((val1 & 0xC0) != 0x80) + return -1; + if ((val0 & 0xE0) == 0xC0) { + ch = (val0 & 0x1F) << 6; + ch |= (val1 & 0x3F); + } + else { + if (str.bufpos >= str.bufeof) + return -1; + val2 = str.buf[str.bufpos]; + str.bufpos++; + if ((val2 & 0xC0) != 0x80) + return -1; + if ((val0 & 0xF0) == 0xE0) { + ch = (((val0 & 0xF)<<12) & 0x0000F000); + ch |= (((val1 & 0x3F)<<6) & 0x00000FC0); + ch |= (((val2 & 0x3F)) & 0x0000003F); + } + else if ((val0 & 0xF0) == 0xF0) { + if (str.bufpos >= str.bufeof) + return -1; + val3 = str.buf[str.bufpos]; + str.bufpos++; + if ((val3 & 0xC0) != 0x80) + return -1; + ch = (((val0 & 0x7)<<18) & 0x1C0000); + ch |= (((val1 & 0x3F)<<12) & 0x03F000); + ch |= (((val2 & 0x3F)<<6) & 0x000FC0); + ch |= (((val3 & 0x3F)) & 0x00003F); + } + else { + return -1; + } + } + } + } + str.readcount++; + ch >>>= 0; + if (!want_unicode && ch >= 0x100) + return 63; // return '?' + return ch; + } + /* non-unicode file/resource, fall through to memory... */ + case strtype_Memory: + if (str.bufpos < str.bufeof) { + ch = str.buf[str.bufpos]; + str.bufpos++; + str.readcount++; + if (!want_unicode && ch >= 0x100) + return 63; // return '?' + return ch; + } + else { + return -1; // end of stream + } + default: + return -1; + } +} + +function gli_get_line(str, buf, want_unicode) { + if (!str || !str.readable) + return 0; + + var len = buf.length; + var lx, ch; + var gotnewline; + + switch (str.type) { + case strtype_File: + if (str.streaming) { + if (len == 0) + return 0; + len -= 1; /* for the terminal null */ + gotnewline = false; + for (lx=0; lx= str.bufeof) { + len = 0; + } + else { + if (str.bufpos + len > str.bufeof) { + len = str.bufeof - str.bufpos; + } + } + gotnewline = false; + if (!want_unicode) { + for (lx=0; lx= 0x100) + ch = 63; // ch = '?' + buf[lx] = ch; + gotnewline = (ch == 10); + } + } + else { + for (lx=0; lx= str.bufeof) { + len = 0; + } + else { + if (str.bufpos + len > str.bufeof) { + len = str.bufeof - str.bufpos; + } + } + if (!want_unicode) { + for (lx=0; lx= 0x100) + ch = 63; // ch = '?' + buf[lx] = ch; + } + } + else { + for (lx=0; lx str.buflen-str.bufpos) + len = str.buflen-str.bufpos; + for (ix=0; ix str.bufeof) + str.bufeof = str.bufpos; + } + break; + case strtype_Memory: + len = val.length; + if (len > str.buflen-str.bufpos) + len = str.buflen-str.bufpos; + if (str.unicode || allbytes) { + for (ix=0; ix= 0x100) + ch = 63; // '?' + str.buf[str.bufpos+ix] = ch; + } + } + str.bufpos += len; + if (str.bufpos > str.bufeof) + str.bufeof = str.bufpos; + break; + case strtype_Window: + if (str.win.line_request) + throw('glk_put_jstring: window has pending line request'); + gli_window_put_string(str.win, val); + if (str.win.echostr) + glk_put_jstring_stream(str.win.echostr, val, allbytes); + break; + } +} + +function gli_set_style(str, val) { + if (!str || !str.writable) + throw('gli_set_style: invalid stream'); + + if (val >= Const.style_NUMSTYLES) + val = 0; + + if (str.type == strtype_Window) { + str.win.style = val; + if (str.win.echostr) + gli_set_style(str.win.echostr, val); + } +} + +function gli_set_hyperlink(str, val) { + if (!str || !str.writable) + throw('gli_set_hyperlink: invalid stream'); + + if (str.type == strtype_Window) { + str.win.hyperlink = val; + if (str.win.echostr) + gli_set_hyperlink(str.win.echostr, val); + } +} + +/* The catalog of Glk API functions. */ + +function glk_exit() { + /* For safety, this is fast and idempotent. */ + has_exited = true; + ui_disabled = true; + gli_selectref = null; + if (option_exit_warning) + GlkOte.warning(option_exit_warning); + return DidNotReturn; +} + +function glk_tick() { + /* Do nothing. */ +} + +function glk_gestalt(sel, val) { + return glk_gestalt_ext(sel, val, null); +} + +function glk_gestalt_ext(sel, val, arr) { + switch (sel) { + + case 0: // gestalt_Version + /* This implements Glk spec version 0.7.6. */ + return 0x00000706; + + case 1: // gestalt_CharInput + /* This is not a terrific approximation. Return false for function + keys, control keys, and the high-bit non-printables. For + everything else in the Unicode range, return true. */ + if (val <= Const.keycode_Left && val >= Const.keycode_End) + return 1; + if (val >= 0x100000000-Const.keycode_MAXVAL) + return 0; + if (val > 0x10FFFF) + return 0; + if ((val >= 0 && val < 32) || (val >= 127 && val < 160)) + return 0; + return 1; + + case 2: // gestalt_LineInput + /* Same as the above, except no special keys. */ + if (val > 0x10FFFF) + return 0; + if ((val >= 0 && val < 32) || (val >= 127 && val < 160)) + return 0; + return 1; + + case 3: // gestalt_CharOutput + /* Same thing again. We assume that all printable characters, + as well as the placeholders for nonprintables, are one character + wide. */ + if ((val > 0x10FFFF) + || (val >= 0 && val < 32) + || (val >= 127 && val < 160)) { + if (arr) + arr[0] = 1; + return 0; // gestalt_CharOutput_CannotPrint + } + if (arr) + arr[0] = 1; + return 2; // gestalt_CharOutput_ExactPrint + + case 4: // gestalt_MouseInput + if (val == Const.wintype_TextBuffer) + return 1; + if (val == Const.wintype_Graphics && has_canvas) + return 1; + return 0; + + case 5: // gestalt_Timer + return 1; + + case 6: // gestalt_Graphics + return 1; + + case 7: // gestalt_DrawImage + case 24: // gestalt_DrawImageScale + if (val == Const.wintype_TextBuffer) + return 1; + if (val == Const.wintype_Graphics && has_canvas) + return 1; + return 0; + + case 8: // gestalt_Sound + return 0; + + case 9: // gestalt_SoundVolume + return 0; + + case 10: // gestalt_SoundNotify + return 0; + + case 11: // gestalt_Hyperlinks + return 1; + + case 12: // gestalt_HyperlinkInput + if (val == 3 || val == 4) // TextBuffer or TextGrid + return 1; + else + return 0; + + case 13: // gestalt_SoundMusic + return 0; + + case 14: // gestalt_GraphicsTransparency + return 1; + + case 15: // gestalt_Unicode + return 1; + + case 16: // gestalt_UnicodeNorm + return 1; + + case 17: // gestalt_LineInputEcho + return 1; + + case 18: // gestalt_LineTerminators + return 1; + + case 19: // gestalt_LineTerminatorKey + /* Really this result should be inspected from glkote.js. Since it + isn't, be sure to keep these values in sync with + terminator_key_names. */ + if (val == Const.keycode_Escape) + return 1; + if (val >= Const.keycode_Func12 && val <= Const.keycode_Func1) + return 1; + return 0; + + case 20: // gestalt_DateTime + return 1; + + case 21: // gestalt_Sound2 + return 0; + + case 22: // gestalt_ResourceStream + return 1; + + case 23: // gestalt_GraphicsCharInput + return 0; + + } + + if (option_glk_gestalt_hook) { + var res = option_glk_gestalt_hook(sel, val, arr); + if (res !== undefined) + return res; + } + + return 0; +} + +function glk_window_iterate(win, rockref) { + if (!win) + win = gli_windowlist; + else + win = win.next; + + if (win) { + if (rockref) + rockref.set_value(win.rock); + return win; + } + + if (rockref) + rockref.set_value(0); + return null; +} + +function glk_window_get_rock(win) { + if (!win) + throw('glk_window_get_rock: invalid window'); + return win.rock; +} + +function glk_window_get_root() { + return gli_rootwin; +} + +function glk_window_open(splitwin, method, size, wintype, rock) { + var oldparent, box, val; + var pairwin, newwin; + + if (!gli_rootwin) { + if (splitwin) + throw('glk_window_open: splitwin must be null for first window'); + + oldparent = null; + box = { + left: content_metrics.outspacingx, + top: content_metrics.outspacingy, + right: content_metrics.width-content_metrics.outspacingx, + bottom: content_metrics.height-content_metrics.outspacingy + }; + } + else { + if (!splitwin) + throw('glk_window_open: splitwin must not be null'); + + val = (method & Const.winmethod_DivisionMask); + if (val != Const.winmethod_Fixed && val != Const.winmethod_Proportional) + throw('glk_window_open: invalid method (not fixed or proportional)'); + + val = (method & Const.winmethod_DirMask); + if (val != Const.winmethod_Above && val != Const.winmethod_Below + && val != Const.winmethod_Left && val != Const.winmethod_Right) + throw('glk_window_open: invalid method (bad direction)'); + + box = splitwin.bbox; + + oldparent = splitwin.parent; + if (oldparent && oldparent.type != Const.wintype_Pair) + throw('glk_window_open: parent window is not Pair'); + } + + newwin = gli_new_window(wintype, rock); + + switch (newwin.type) { + case Const.wintype_TextBuffer: + /* accum is a list of strings of a given style; newly-printed text + is pushed onto the list. accumstyle is the style of that text. + Anything printed in a different style (or hyperlink value) + triggers a call to gli_window_buffer_deaccumulate, which cleans + out accum and adds the results to the content array. The content + is in GlkOte format. + */ + newwin.accum = []; + newwin.accumstyle = null; + newwin.accumhyperlink = 0; + newwin.content = []; + newwin.clearcontent = false; + newwin.reserve = []; /* autosave of recent content */ + break; + case Const.wintype_TextGrid: + /* lines is a list of line objects. A line looks like + { chars: [...], styles: [...], hyperlinks: [...], dirty: bool }. + */ + newwin.gridwidth = 0; + newwin.gridheight = 0; + newwin.lines = []; + newwin.cursorx = 0; + newwin.cursory = 0; + break; + case Const.wintype_Graphics: + if (!has_canvas) { + /* Graphics windows not supported; silently return null */ + gli_delete_window(newwin); + return null; + } + newwin.content = []; + newwin.reserve = []; /* autosave of recent content */ + break; + case Const.wintype_Blank: + break; + case Const.wintype_Pair: + throw('glk_window_open: cannot open pair window directly') + default: + /* Silently return null */ + gli_delete_window(newwin); + return null; + } + + if (!splitwin) { + gli_rootwin = newwin; + gli_window_rearrange(newwin, box); + } + else { + /* create pairwin, with newwin as the key */ + pairwin = gli_new_window(Const.wintype_Pair, 0); + pairwin.pair_dir = method & Const.winmethod_DirMask; + pairwin.pair_division = method & Const.winmethod_DivisionMask; + pairwin.pair_key = newwin; + pairwin.pair_keydamage = false; + pairwin.pair_size = size; + pairwin.pair_hasborder = ((method & Const.winmethod_BorderMask) == Const.winmethod_Border); + pairwin.pair_vertical = (pairwin.pair_dir == Const.winmethod_Left || pairwin.pair_dir == Const.winmethod_Right); + pairwin.pair_backward = (pairwin.pair_dir == Const.winmethod_Left || pairwin.pair_dir == Const.winmethod_Above); + + pairwin.child1 = splitwin; + pairwin.child2 = newwin; + splitwin.parent = pairwin; + newwin.parent = pairwin; + pairwin.parent = oldparent; + + if (oldparent) { + if (oldparent.child1 == splitwin) + oldparent.child1 = pairwin; + else + oldparent.child2 = pairwin; + } + else { + gli_rootwin = pairwin; + } + + gli_window_rearrange(pairwin, box); + } + + return newwin; +} + +function glk_window_close(win, statsref) { + if (!win) + throw('glk_window_close: invalid window'); + + if (win === gli_rootwin || !win.parent) { + /* close the root window, which means all windows. */ + + gli_rootwin = null; + + /* begin (simpler) closation */ + + gli_stream_fill_result(win.str, statsref); + gli_window_close(win, true); + } + else { + /* have to jigger parent */ + var pairwin, grandparwin, sibwin, box, wx, keydamage_flag; + + pairwin = win.parent; + if (win === pairwin.child1) + sibwin = pairwin.child2; + else if (win === pairwin.child2) + sibwin = pairwin.child1; + else + throw('glk_window_close: window tree is corrupted'); + + box = pairwin.bbox; + + grandparwin = pairwin.parent; + if (!grandparwin) { + gli_rootwin = sibwin; + sibwin.parent = null; + } + else { + if (grandparwin.child1 === pairwin) + grandparwin.child1 = sibwin; + else + grandparwin.child2 = sibwin; + sibwin.parent = grandparwin; + } + + /* Begin closation */ + + gli_stream_fill_result(win.str, statsref); + + /* Close the child window (and descendants), so that key-deletion can + crawl up the tree to the root window. */ + gli_window_close(win, true); + + /* This probably isn't necessary, but the child *is* gone, so just + in case. */ + if (win === pairwin.child1) { + pairwin.child1 = null; + } + else if (win === pairwin.child2) { + pairwin.child2 = null; + } + + /* Now we can delete the parent pair. */ + gli_window_close(pairwin, false); + + keydamage_flag = false; + for (wx=sibwin; wx; wx=wx.parent) { + if (wx.type == Const.wintype_Pair) { + if (wx.pair_keydamage) { + keydamage_flag = true; + wx.pair_keydamage = false; + } + } + } + + if (keydamage_flag) { + box = content_box; + gli_window_rearrange(gli_rootwin, box); + } + else { + gli_window_rearrange(sibwin, box); + } + } +} + +function glk_window_get_size(win, widthref, heightref) { + if (!win) + throw('glk_window_get_size: invalid window'); + + var wid = 0; + var hgt = 0; + var boxwidth, boxheight; + + switch (win.type) { + + case Const.wintype_TextGrid: + boxwidth = win.bbox.right - win.bbox.left; + boxheight = win.bbox.bottom - win.bbox.top; + wid = Math.max(0, Math.floor((boxwidth-content_metrics.gridmarginx) / content_metrics.gridcharwidth)); + hgt = Math.max(0, Math.floor((boxheight-content_metrics.gridmarginy) / content_metrics.gridcharheight)); + break; + + case Const.wintype_TextBuffer: + boxwidth = win.bbox.right - win.bbox.left; + boxheight = win.bbox.bottom - win.bbox.top; + wid = Math.max(0, Math.floor((boxwidth-content_metrics.buffermarginx) / content_metrics.buffercharwidth)); + hgt = Math.max(0, Math.floor((boxheight-content_metrics.buffermarginy) / content_metrics.buffercharheight)); + break; + + case Const.wintype_Graphics: + boxwidth = win.bbox.right - win.bbox.left; + boxheight = win.bbox.bottom - win.bbox.top; + wid = boxwidth - content_metrics.graphicsmarginx; + hgt = boxheight - content_metrics.graphicsmarginy; + break; + } + + if (widthref) + widthref.set_value(wid); + if (heightref) + heightref.set_value(hgt); +} + +function glk_window_set_arrangement(win, method, size, keywin) { + var wx, newdir, newvertical, newbackward; + + if (!win) + throw('glk_window_set_arrangement: invalid window'); + if (win.type != Const.wintype_Pair) + throw('glk_window_set_arrangement: not a pair window'); + + if (keywin) { + if (keywin.type == Const.wintype_Pair) + throw('glk_window_set_arrangement: keywin cannot be a pair window'); + for (wx=keywin; wx; wx=wx.parent) { + if (wx == win) + break; + } + if (!wx) + throw('glk_window_set_arrangement: keywin must be a descendant'); + } + + newdir = method & Const.winmethod_DirMask; + newvertical = (newdir == Const.winmethod_Left || newdir == Const.winmethod_Right); + newbackward = (newdir == Const.winmethod_Left || newdir == Const.winmethod_Above); + if (!keywin) + keywin = win.pair_key; + + if (newvertical && !win.pair_vertical) + throw('glk_window_set_arrangement: split must stay horizontal'); + if (!newvertical && win.pair_vertical) + throw('glk_window_set_arrangement: split must stay vertical'); + + if (keywin && keywin.type == Const.wintype_Blank + && (method & Const.winmethod_DivisionMask) == Const.winmethod_Fixed) + throw('glk_window_set_arrangement: a blank window cannot have a fixed size'); + + if ((newbackward && !win.pair_backward) || (!newbackward && win.pair_backward)) { + /* switch the children */ + wx = win.child1; + win.child1 = win.child2; + win.child2 = wx; + } + + /* set up everything else */ + win.pair_dir = newdir; + win.pair_division = (method & Const.winmethod_DivisionMask); + win.pair_key = keywin; + win.pair_size = size; + + win.pair_hasborder = ((method & Const.winmethod_BorderMask) == Const.winmethod_Border); + win.pair_vertical = (win.pair_dir == Const.winmethod_Left || win.pair_dir == Const.winmethod_Right); + win.pair_backward = (win.pair_dir == Const.winmethod_Left || win.pair_dir == Const.winmethod_Above); + + gli_window_rearrange(win, win.bbox); +} + +function glk_window_get_arrangement(win, methodref, sizeref, keywinref) { + if (!win) + throw('glk_window_get_arrangement: invalid window'); + if (win.type != Const.wintype_Pair) + throw('glk_window_get_arrangement: not a pair window'); + + if (sizeref) + sizeref.set_value(win.pair_size); + if (keywinref) + keywinref.set_value(win.pair_key); + if (methodref) + methodref.set_value(win.pair_dir | win.pair_division | (win.pair_hasborder ? Const.winmethod_Border : Const.winmethod_NoBorder)); +} + +function glk_window_get_type(win) { + if (!win) + throw('glk_window_get_type: invalid window'); + return win.type; +} + +function glk_window_get_parent(win) { + if (!win) + throw('glk_window_get_parent: invalid window'); + return win.parent; +} + +function glk_window_clear(win) { + var ix, cx, lineobj; + + if (!win) + throw('glk_window_clear: invalid window'); + + if (win.line_request) { + throw('glk_window_clear: window has pending line request'); + } + + switch (win.type) { + case Const.wintype_TextBuffer: + win.accum.length = 0; + win.accumstyle = null; + win.accumhyperlink = 0; + win.content.length = 0; + win.clearcontent = true; + break; + case Const.wintype_TextGrid: + win.cursorx = 0; + win.cursory = 0; + for (ix=0; ix str.bufeof) + pos = str.bufeof; + str.bufpos = pos; + } +} + +function glk_stream_get_position(str) { + if (!str) + throw('glk_stream_get_position: invalid stream'); + + switch (str.type) { + case strtype_File: + if (str.streaming) { + return str.fstream.ftell(); + } + /* fall through to memory... */ + case strtype_Resource: + /* fall through to memory... */ + case strtype_Memory: + return str.bufpos; + default: + return 0; + } +} + +function glk_stream_set_current(str) { + gli_currentstr = str; +} + +function glk_stream_get_current() { + return gli_currentstr; +} + +function glk_fileref_create_temp(usage, rock) { + var Dialog = GlkOte.getlibrary('Dialog'); + var filetype = (usage & Const.fileusage_TypeMask); + var filetypename = FileTypeMap[filetype]; + var ref = Dialog.file_construct_temp_ref(filetypename); + var fref = gli_new_fileref(ref.filename, usage, rock, ref); + return fref; +} + +function glk_fileref_create_by_name(usage, filename, rock) { + var Dialog = GlkOte.getlibrary('Dialog'); + + /* Filenames that do not come from the user must be cleaned up. */ + filename = Dialog.file_clean_fixed_name(filename, (usage & Const.fileusage_TypeMask)); + + var fref = gli_new_fileref(filename, usage, rock, null); + return fref; +} + +function glk_fileref_create_by_prompt(usage, fmode, rock) { + var modename; + + var filetype = (usage & Const.fileusage_TypeMask); + var filetypename = FileTypeMap[filetype]; + if (!filetypename) { + filetypename = 'xxx'; + } + + switch (fmode) { + case Const.filemode_Write: + modename = 'write'; + break; + case Const.filemode_ReadWrite: + modename = 'readwrite'; + break; + case Const.filemode_WriteAppend: + modename = 'writeappend'; + break; + case Const.filemode_Read: + default: + modename = 'read'; + break; + } + + var special = { + type: 'fileref_prompt', + filetype: filetypename, + filemode: modename + }; + var callback = { + usage: usage, + rock: rock + }; + + if (filetype == Const.fileusage_SavedGame) + special.gameid = VM.get_signature(); + + ui_specialinput = special; + ui_specialcallback = callback; + gli_selectref = null; + return DidNotReturn; +} + +function gli_fileref_create_by_prompt_callback(obj) { + var ref = obj.value; + /* This "value" field will be a dialog.js fileref object if we are + connected to GlkOte. However, if we are connected to RegTest, + it will be a plain string. We attempt to handle both cases. */ + + var usage = ui_specialcallback.usage; + var rock = ui_specialcallback.rock; + + var fref = null; + if (ref && typeof(ref) == 'object') { + fref = gli_new_fileref(ref.filename, usage, rock, ref); + } + else if (ref && typeof(ref) == 'string') { + fref = gli_new_fileref(ref, usage, rock, null); + } + + ui_specialinput = null; + ui_specialcallback = null; + + if (GiDispa) + GiDispa.prepare_resume(fref); + VM.resume(fref); +} + +function glk_fileref_destroy(fref) { + if (!fref) + throw('glk_fileref_destroy: invalid fileref'); + gli_delete_fileref(fref); +} + +function glk_fileref_iterate(fref, rockref) { + if (!fref) + fref = gli_filereflist; + else + fref = fref.next; + + if (fref) { + if (rockref) + rockref.set_value(fref.rock); + return fref; + } + + if (rockref) + rockref.set_value(0); + return null; +} + +function glk_fileref_get_rock(fref) { + if (!fref) + throw('glk_fileref_get_rock: invalid fileref'); + return fref.rock; +} + +function glk_fileref_delete_file(fref) { + var Dialog = GlkOte.getlibrary('Dialog'); + if (!fref) + throw('glk_fileref_delete_file: invalid fileref'); + Dialog.file_remove_ref(fref.ref); +} + +function glk_fileref_does_file_exist(fref) { + var Dialog = GlkOte.getlibrary('Dialog'); + if (!fref) + throw('glk_fileref_does_file_exist: invalid fileref'); + if (Dialog.file_ref_exists(fref.ref)) + return 1; + else + return 0; +} + +function glk_fileref_create_from_fileref(usage, oldfref, rock) { + if (!oldfref) + throw('glk_fileref_create_from_fileref: invalid fileref'); + + var fref = gli_new_fileref(oldfref.filename, usage, rock, null); + return fref; +} + +function glk_put_char(ch) { + gli_put_char(gli_currentstr, ch & 0xFF); +} + +function glk_put_char_stream(str, ch) { + gli_put_char(str, ch & 0xFF); +} + +function glk_put_string(val) { + glk_put_jstring_stream(gli_currentstr, val, true); +} + +function glk_put_string_stream(str, val) { + glk_put_jstring_stream(str, val, true); +} + +function glk_put_buffer(arr) { + arr = TrimArrayToBytes(arr); + gli_put_array(gli_currentstr, arr, true); +} + +function glk_put_buffer_stream(str, arr) { + arr = TrimArrayToBytes(arr); + gli_put_array(str, arr, true); +} + +function glk_set_style(val) { + gli_set_style(gli_currentstr, val); +} + +function glk_set_style_stream(str, val) { + gli_set_style(str, val); +} + +function glk_get_char_stream(str) { + if (!str) + throw('glk_get_char_stream: invalid stream'); + return gli_get_char(str, false); +} + +function glk_get_line_stream(str, buf) { + if (!str) + throw('glk_get_line_stream: invalid stream'); + return gli_get_line(str, buf, false); +} + +function glk_get_buffer_stream(str, buf) { + if (!str) + throw('glk_get_buffer_stream: invalid stream'); + return gli_get_buffer(str, buf, false); +} + +function glk_char_to_lower(val) { + if (val >= 0x41 && val <= 0x5A) + return val + 0x20; + if (val >= 0xC0 && val <= 0xDE && val != 0xD7) + return val + 0x20; + return val; +} + +function glk_char_to_upper(val) { + if (val >= 0x61 && val <= 0x7A) + return val - 0x20; + if (val >= 0xE0 && val <= 0xFE && val != 0xF7) + return val - 0x20; + return val; +} + +/* Style hints are not supported. We will use the new style system. */ +function glk_stylehint_set(wintype, styl, hint, value) { } +function glk_stylehint_clear(wintype, styl, hint) { } +function glk_style_distinguish(win, styl1, styl2) { + return 0; +} +function glk_style_measure(win, styl, hint, resultref) { + if (resultref) + resultref.set_value(0); + return 0; +} + +function glk_select(eventref) { + gli_selectref = eventref; + return DidNotReturn; +} + +function glk_select_poll(eventref) { + /* Because the Javascript interpreter is single-threaded, we cannot + have gotten a timer event since the last glk_select call. */ + + eventref.set_field(0, Const.evtype_None); + eventref.set_field(1, null); + eventref.set_field(2, 0); + eventref.set_field(3, 0); + + if (gli_timer_interval) { + var now = Date.now(); + if (now - gli_timer_started > gli_timer_interval) { + /* We're past the timer interval, even though we got no + event. Let's pretend we did, reset it, and return a + timer event. */ + gli_timer_started = Date.now(); + /* Resend timer request at next update. */ + gli_timer_lastsent = null; + + eventref.set_field(0, Const.evtype_Timer); + } + } +} + +function glk_request_line_event(win, buf, initlen) { + if (!win) + throw('glk_request_line_event: invalid window'); + if (win.char_request || win.line_request) + throw('glk_request_line_event: window already has keyboard request'); + + if (win.type == Const.wintype_TextBuffer + || win.type == Const.wintype_TextGrid) { + if (initlen) { + /* This will be copied into the next update. */ + var ls = buf.slice(0, initlen); + if (!current_partial_outputs) + current_partial_outputs = {}; + current_partial_outputs[win.disprock] = ByteArrayToString(ls); + } + win.line_request = true; + win.line_request_uni = false; + if (win.type == Const.wintype_TextBuffer) + win.request_echo_line_input = win.echo_line_input; + else + win.request_echo_line_input = true; + win.input_generation = event_generation; + win.linebuf = buf; + if (GiDispa) + GiDispa.retain_array(buf); + } + else { + throw('glk_request_line_event: window does not support keyboard input'); + } +} + +function glk_cancel_line_event(win, eventref) { + if (!win) + throw('glk_cancel_line_event: invalid window'); + + if (!win.line_request) { + if (eventref) { + eventref.set_field(0, Const.evtype_None); + eventref.set_field(1, null); + eventref.set_field(2, 0); + eventref.set_field(3, 0); + } + return; + } + + var input = ""; + var ix, val; + + if (current_partial_inputs) { + val = current_partial_inputs[win.disprock]; + if (val) + input = val; + } + + if (input.length > win.linebuf.length) + input = input.slice(0, win.linebuf.length); + + if (win.request_echo_line_input) { + ix = win.style; + gli_set_style(win.str, Const.style_Input); + gli_window_put_string(win, input); + if (win.echostr) + glk_put_jstring_stream(win.echostr, input); + gli_set_style(win.str, ix); + gli_window_put_string(win, "\n"); + if (win.echostr) + glk_put_jstring_stream(win.echostr, "\n"); + } + + for (ix=0; ix img.winmaxwidth) { + if (img.height !== undefined) + img.height = img.height * (img.winmaxwidth / img.widthratio); + img.widthratio = img.winmaxwidth; + } + img.winmaxwidth = null; + } + } + + switch (win.type) { + case Const.wintype_TextBuffer: + var alignment = 'inlineup'; + switch (val1) { + case Const.imagealign_InlineUp: + alignment = 'inlineup'; + break; + case Const.imagealign_InlineDown: + alignment = 'inlinedown'; + break; + case Const.imagealign_InlineCenter: + alignment = 'inlinecenter'; + break; + case Const.imagealign_MarginLeft: + alignment = 'marginleft'; + break; + case Const.imagealign_MarginRight: + alignment = 'marginright'; + break; + } + img.alignment = alignment; + if (win.hyperlink) + img.hyperlink = win.hyperlink; + gli_window_buffer_put_special(win, img); + return 1; + + case Const.wintype_Graphics: + img.x = val1; + img.y = val2; + /* width, height already set */ + win.content.push(img); + return 1; + } + + return 0; +} + +function glk_window_flow_break(win) { + if (!win) + throw('glk_window_flow_break: invalid window'); + + if (win.type == Const.wintype_TextBuffer) + gli_window_buffer_put_special(win, undefined, true); +} + +function glk_window_erase_rect(win, left, top, width, height) { + if (!win) + throw('glk_window_erase_rect: invalid window'); + if (win.type != Const.wintype_Graphics) + throw('glk_window_erase_rect: not a graphics window'); + + win.content.push({ special:'fill', x:left, y:top, width:width, height:height }); +} + +function glk_window_fill_rect(win, color, left, top, width, height) { + if (!win) + throw('glk_window_fill_rect: invalid window'); + if (win.type != Const.wintype_Graphics) + throw('glk_window_fill_rect: not a graphics window'); + + var colstr = gli_color_to_css(color); + win.content.push({ special:'fill', color:colstr, x:left, y:top, width:width, height:height }); +} + +function glk_window_set_background_color(win, color) { + if (!win) + throw('glk_window_set_background_color: invalid window'); + if (win.type != Const.wintype_Graphics) + throw('glk_window_set_background_color: not a graphics window'); + + var colstr = gli_color_to_css(color); + win.content.push({ special:'setcolor', color:colstr }); +} + +/* Convert a Glk numeric color value to a CSS-compatible string. +*/ +function gli_color_to_css(color) { + var res = (color & 0xFFFFFF).toString(16); + while (res.length < 6) { + res = '0' + res; + } + return '#' + res.toUpperCase(); +} + +function glk_schannel_iterate(schan, rockref) { + if (!schan) + schan = gli_schannellist; + else + schan = schan.next; + + if (schan) { + if (rockref) + rockref.set_value(schan.rock); + return schan; + } + + if (rockref) + rockref.set_value(0); + return null; +} + +function glk_schannel_get_rock(schan) { + if (!schan) + throw('glk_schannel_get_rock: invalid schannel'); + return schan.rock; +} + +function glk_schannel_create(rock) { + return null; +} + +function glk_schannel_destroy(schan) { + throw('glk_schannel_destroy: invalid schannel'); +} + +function glk_schannel_play(schan, sndid) { + throw('glk_schannel_play: invalid schannel'); +} + +function glk_schannel_play_ext(schan, sndid, repeats, notify) { + throw('glk_schannel_play_ext: invalid schannel'); +} + +function glk_schannel_stop(schan) { + throw('glk_schannel_stop: invalid schannel'); +} + +function glk_schannel_set_volume(schan, vol) { + throw('glk_schannel_set_volume: invalid schannel'); +} + +function glk_sound_load_hint(sndid, flag) { +} + +function glk_schannel_create_ext(rock, vol) { + return null; +} + +function glk_schannel_play_multi(schans, sndids, notify) { + throw('glk_schannel_play_multi: invalid schannel'); +} + +function glk_schannel_pause(schan) { + throw('glk_schannel_pause: invalid schannel'); +} + +function glk_schannel_unpause(schan) { + throw('glk_schannel_unpause: invalid schannel'); +} + +function glk_schannel_set_volume_ext(schan, vol, duration, notify) { + throw('glk_schannel_set_volume_ext: invalid schannel'); +} + +function glk_set_hyperlink(val) { + gli_set_hyperlink(gli_currentstr, val); +} + +function glk_set_hyperlink_stream(str, val) { + gli_set_hyperlink(str, val); +} + +function glk_request_hyperlink_event(win) { + if (!win) + throw('glk_request_hyperlink_event: invalid window'); + if (win.type == Const.wintype_TextBuffer + || win.type == Const.wintype_TextGrid) { + win.hyperlink_request = true; + } +} + +function glk_cancel_hyperlink_event(win) { + if (!win) + throw('glk_cancel_hyperlink_event: invalid window'); + if (win.type == Const.wintype_TextBuffer + || win.type == Const.wintype_TextGrid) { + win.hyperlink_request = false; + } +} + +function glk_buffer_to_lower_case_uni(arr, numchars) { + var ix, jx, pos, val, origval; + var arrlen = arr.length; + var src = arr.slice(0, numchars); + + if (arrlen < numchars) + throw('buffer_to_lower_case_uni: numchars exceeds array length'); + + pos = 0; + for (ix=0; ix= pos) + break; + grpstart = ix; + while (ix < pos && unicode_combin_table[arr[ix]]) + ix++; + grpend = ix; + if (grpend - grpstart >= 2) { + /* Sort this group. */ + for (jx = grpend-1; jx > grpstart; jx--) { + for (kx = grpstart; kx < jx; kx++) { + if (unicode_combin_table[arr[kx]] > unicode_combin_table[arr[kx+1]]) { + tmp = arr[kx]; + arr[kx] = arr[kx+1]; + arr[kx+1] = tmp; + } + } + } + } + } + + return pos; +} + +function gli_buffer_canon_compose_uni(arr, numchars) { + /* The algorithm for canonically composing characters in a string: + for each base character, compare it to all the following + combining characters (up to the next base character). If they're + composable, compose them. Repeat until no more pairs are found. */ + + var ix, jx, curch, newch, curclass, newclass, map, pos; + + if (numchars == 0) + return 0; + + pos = 0; + curch = arr[0]; + curclass = unicode_combin_table[curch]; + if (curclass) + curclass = 999; // just in case the first character is a combiner + ix = 1; + jx = ix; + while (true) { + if (jx >= numchars) { + arr[pos] = curch; + pos = ix; + break; + } + newch = arr[jx]; + newclass = unicode_combin_table[newch]; + map = unicode_compo_table[curch]; + if (map !== undefined && map[newch] !== undefined + && (!curclass || (newclass && curclass < newclass))) { + curch = map[newch]; + arr[pos] = curch; + } + else { + if (!newclass) { + pos = ix; + curch = newch; + } + curclass = newclass; + arr[ix] = newch; + ix++; + } + jx++; + } + + return pos; +} + +function glk_buffer_canon_decompose_uni(arr, numchars) { + var arrlen = arr.length; + var len; + + len = gli_buffer_canon_decompose_uni(arr, numchars); + + /* in case we stretched the array */ + arr.length = arrlen; + + return len; +} + +function glk_buffer_canon_normalize_uni(arr, numchars) { + var arrlen = arr.length; + var len; + + len = gli_buffer_canon_decompose_uni(arr, numchars); + len = gli_buffer_canon_compose_uni(arr, len); + + /* in case we stretched the array */ + arr.length = arrlen; + + return len; +} + +function glk_put_char_uni(ch) { + gli_put_char(gli_currentstr, ch); +} + +function glk_put_string_uni(val) { + glk_put_jstring_stream(gli_currentstr, val, false); +} + +function glk_put_buffer_uni(arr) { + gli_put_array(gli_currentstr, arr, false); +} + +function glk_put_char_stream_uni(str, ch) { + gli_put_char(str, ch); +} + +function glk_put_string_stream_uni(str, val) { + glk_put_jstring_stream(str, val, false); +} + +function glk_put_buffer_stream_uni(str, arr) { + gli_put_array(str, arr, false); +} + +function glk_get_char_stream_uni(str) { + if (!str) + throw('glk_get_char_stream_uni: invalid stream'); + return gli_get_char(str, true); +} + +function glk_get_buffer_stream_uni(str, buf) { + if (!str) + throw('glk_get_buffer_stream_uni: invalid stream'); + return gli_get_buffer(str, buf, true); +} + +function glk_get_line_stream_uni(str, buf) { + if (!str) + throw('glk_get_line_stream_uni: invalid stream'); + return gli_get_line(str, buf, true); +} + +function glk_stream_open_file_uni(fref, fmode, rock) { + var Dialog = GlkOte.getlibrary('Dialog'); + + if (!fref) + throw('glk_stream_open_file_uni: invalid fileref'); + + var str; + var fstream; + + if (fmode != Const.filemode_Read + && fmode != Const.filemode_Write + && fmode != Const.filemode_ReadWrite + && fmode != Const.filemode_WriteAppend) + throw('glk_stream_open_file_uni: illegal filemode'); + + if (fmode == Const.filemode_Read && !Dialog.file_ref_exists(fref.ref)) + return null; + + if (!Dialog.streaming) { + var content = null; + if (fmode != Const.filemode_Write) { + content = Dialog.file_read(fref.ref); + } + if (content == null) { + content = []; + if (fmode != Const.filemode_Read) { + /* We just created this file. (Or perhaps we're in Write mode + and we're truncating.) Write immediately, to create it and + get the creation date right. */ + Dialog.file_write(fref.ref, '', true); + } + } + if (content.length == null) + throw('glk_stream_open_file_uni: data read had no length'); + } + else { + fstream = Dialog.file_fopen(fmode, fref.ref); + if (!fstream) + return null; + } + + str = gli_new_stream(strtype_File, + (fmode != Const.filemode_Write), + (fmode != Const.filemode_Read), + rock); + str.unicode = true; + str.isbinary = !fref.textmode; + str.ref = fref.ref; + str.origfmode = fmode; + + if (!Dialog.streaming) { + str.streaming = false; + str.buf = content; + str.buflen = 0xFFFFFFFF; /* enormous */ + if (fmode == Const.filemode_Write) + str.bufeof = 0; + else + str.bufeof = content.length; + if (fmode == Const.filemode_WriteAppend) + str.bufpos = str.bufeof; + else + str.bufpos = 0; + } + else { + str.streaming = true; + str.fstream = fstream; + /* We'll want a Buffer object around for short and writes. */ + str.buffer4 = fstream.BufferClass.alloc(4); + } + + return str; +} + +function glk_stream_open_memory_uni(buf, fmode, rock) { + var str; + + if (fmode != Const.filemode_Read + && fmode != Const.filemode_Write + && fmode != Const.filemode_ReadWrite) + throw('glk_stream_open_memory: illegal filemode'); + + str = gli_new_stream(strtype_Memory, + (fmode != Const.filemode_Write), + (fmode != Const.filemode_Read), + rock); + str.unicode = true; + + if (buf) { + str.buf = buf; + str.buflen = buf.length; + str.bufpos = 0; + if (fmode == Const.filemode_Write) + str.bufeof = 0; + else + str.bufeof = str.buflen; + if (GiDispa) + GiDispa.retain_array(buf); + } + + return str; +} + +function glk_request_char_event_uni(win) { + if (!win) + throw('glk_request_char_event: invalid window'); + if (win.char_request || win.line_request) + throw('glk_request_char_event: window already has keyboard request'); + + if (win.type == Const.wintype_TextBuffer + || win.type == Const.wintype_TextGrid) { + win.char_request = true; + win.char_request_uni = true; + win.input_generation = event_generation; + } + else { + /* ### wintype_Graphics could accept char input if we set up the focus to allow it. See gestalt_GraphicsCharInput. */ + throw('glk_request_char_event: window does not support keyboard input'); + } +} + +function glk_request_line_event_uni(win, buf, initlen) { + if (!win) + throw('glk_request_line_event: invalid window'); + if (win.char_request || win.line_request) + throw('glk_request_line_event: window already has keyboard request'); + + if (win.type == Const.wintype_TextBuffer + || win.type == Const.wintype_TextGrid) { + if (initlen) { + /* This will be copied into the next update. */ + var ls = buf.slice(0, initlen); + if (!current_partial_outputs) + current_partial_outputs = {}; + current_partial_outputs[win.disprock] = UniArrayToString(ls); + } + win.line_request = true; + win.line_request_uni = true; + if (win.type == Const.wintype_TextBuffer) + win.request_echo_line_input = win.echo_line_input; + else + win.request_echo_line_input = true; + win.input_generation = event_generation; + win.linebuf = buf; + if (GiDispa) + GiDispa.retain_array(buf); + } + else { + throw('glk_request_line_event: window does not support keyboard input'); + } +} + +function glk_current_time(timevalref) { + var now = new Date().getTime(); + var usec; + + timevalref.set_field(0, Math.floor(now / 4294967296000)); + timevalref.set_field(1, Math.floor(now / 1000) >>>0); + usec = Math.floor((now % 1000) * 1000); + if (usec < 0) + usec = 1000000 + usec; + timevalref.set_field(2, usec); +} + +function glk_current_simple_time(factor) { + var now = new Date().getTime(); + return Math.floor(now / (factor * 1000)); +} + +function glk_time_to_date_utc(timevalref, dateref) { + var now = timevalref.get_field(0) * 4294967296000 + timevalref.get_field(1) * 1000 + timevalref.get_field(2) / 1000; + var obj = new Date(now); + + dateref.set_field(0, obj.getUTCFullYear()) + dateref.set_field(1, 1+obj.getUTCMonth()) + dateref.set_field(2, obj.getUTCDate()) + dateref.set_field(3, obj.getUTCDay()) + dateref.set_field(4, obj.getUTCHours()) + dateref.set_field(5, obj.getUTCMinutes()) + dateref.set_field(6, obj.getUTCSeconds()) + dateref.set_field(7, 1000*obj.getUTCMilliseconds()) +} + +function glk_time_to_date_local(timevalref, dateref) { + var now = timevalref.get_field(0) * 4294967296000 + timevalref.get_field(1) * 1000 + timevalref.get_field(2) / 1000; + var obj = new Date(now); + + dateref.set_field(0, obj.getFullYear()) + dateref.set_field(1, 1+obj.getMonth()) + dateref.set_field(2, obj.getDate()) + dateref.set_field(3, obj.getDay()) + dateref.set_field(4, obj.getHours()) + dateref.set_field(5, obj.getMinutes()) + dateref.set_field(6, obj.getSeconds()) + dateref.set_field(7, 1000*obj.getMilliseconds()) +} + +function glk_simple_time_to_date_utc(time, factor, dateref) { + var now = time*(1000*factor); + var obj = new Date(now); + + dateref.set_field(0, obj.getUTCFullYear()) + dateref.set_field(1, 1+obj.getUTCMonth()) + dateref.set_field(2, obj.getUTCDate()) + dateref.set_field(3, obj.getUTCDay()) + dateref.set_field(4, obj.getUTCHours()) + dateref.set_field(5, obj.getUTCMinutes()) + dateref.set_field(6, obj.getUTCSeconds()) + dateref.set_field(7, 1000*obj.getUTCMilliseconds()) +} + +function glk_simple_time_to_date_local(time, factor, dateref) { + var now = time*(1000*factor); + var obj = new Date(now); + + dateref.set_field(0, obj.getFullYear()) + dateref.set_field(1, 1+obj.getMonth()) + dateref.set_field(2, obj.getDate()) + dateref.set_field(3, obj.getDay()) + dateref.set_field(4, obj.getHours()) + dateref.set_field(5, obj.getMinutes()) + dateref.set_field(6, obj.getSeconds()) + dateref.set_field(7, 1000*obj.getMilliseconds()) +} + +function glk_date_to_time_utc(dateref, timevalref) { + var obj = new Date(0); + + obj.setUTCFullYear(dateref.get_field(0)); + obj.setUTCMonth(dateref.get_field(1)-1); + obj.setUTCDate(dateref.get_field(2)); + obj.setUTCHours(dateref.get_field(4)); + obj.setUTCMinutes(dateref.get_field(5)); + obj.setUTCSeconds(dateref.get_field(6)); + obj.setUTCMilliseconds(dateref.get_field(7)/1000); + + var now = obj.getTime(); + var usec; + + timevalref.set_field(0, Math.floor(now / 4294967296000)); + timevalref.set_field(1, Math.floor(now / 1000) >>>0); + usec = Math.floor((now % 1000) * 1000); + if (usec < 0) + usec = 1000000 + usec; + timevalref.set_field(2, usec); +} + +function glk_date_to_time_local(dateref, timevalref) { + var obj = new Date( + dateref.get_field(0), dateref.get_field(1)-1, dateref.get_field(2), + dateref.get_field(4), dateref.get_field(5), dateref.get_field(6), + dateref.get_field(7)/1000); + + var now = obj.getTime(); + var usec; + + timevalref.set_field(0, Math.floor(now / 4294967296000)); + timevalref.set_field(1, Math.floor(now / 1000) >>>0); + usec = Math.floor((now % 1000) * 1000); + if (usec < 0) + usec = 1000000 + usec; + timevalref.set_field(2, usec); +} + +function glk_date_to_simple_time_utc(dateref, factor) { + var obj = new Date(0); + + obj.setUTCFullYear(dateref.get_field(0)); + obj.setUTCMonth(dateref.get_field(1)-1); + obj.setUTCDate(dateref.get_field(2)); + obj.setUTCHours(dateref.get_field(4)); + obj.setUTCMinutes(dateref.get_field(5)); + obj.setUTCSeconds(dateref.get_field(6)); + obj.setUTCMilliseconds(dateref.get_field(7)/1000); + + var now = obj.getTime(); + return Math.floor(now / (factor * 1000)); +} + +function glk_date_to_simple_time_local(dateref, factor) { + var obj = new Date( + dateref.get_field(0), dateref.get_field(1)-1, dateref.get_field(2), + dateref.get_field(4), dateref.get_field(5), dateref.get_field(6), + dateref.get_field(7)/1000); + + var now = obj.getTime(); + return Math.floor(now / (factor * 1000)); +} + +/* End of Glk namespace function. Return the object which will + become the Glk global. */ +return { + classname: 'Glk', + version: '2.3.7', /* GlkOte/GlkApi version */ + init : init, + inited : is_inited, + update : update, + getlibrary : get_library, + save_allstate : save_allstate, + restore_allstate : restore_allstate, + fatal_error : fatal_error, + + byte_array_to_string : ByteArrayToString, + uni_array_to_string : UniArrayToString, + Const : Const, + RefBox : RefBox, + RefStruct : RefStruct, + DidNotReturn : DidNotReturn, + call_may_not_return : call_may_not_return, + + glk_put_jstring : glk_put_jstring, + glk_put_jstring_stream : glk_put_jstring_stream, + + glk_exit : glk_exit, + glk_tick : glk_tick, + glk_gestalt : glk_gestalt, + glk_gestalt_ext : glk_gestalt_ext, + glk_window_iterate : glk_window_iterate, + glk_window_get_rock : glk_window_get_rock, + glk_window_get_root : glk_window_get_root, + glk_window_open : glk_window_open, + glk_window_close : glk_window_close, + glk_window_get_size : glk_window_get_size, + glk_window_set_arrangement : glk_window_set_arrangement, + glk_window_get_arrangement : glk_window_get_arrangement, + glk_window_get_type : glk_window_get_type, + glk_window_get_parent : glk_window_get_parent, + glk_window_clear : glk_window_clear, + glk_window_move_cursor : glk_window_move_cursor, + glk_window_get_stream : glk_window_get_stream, + glk_window_set_echo_stream : glk_window_set_echo_stream, + glk_window_get_echo_stream : glk_window_get_echo_stream, + glk_set_window : glk_set_window, + glk_window_get_sibling : glk_window_get_sibling, + glk_stream_iterate : glk_stream_iterate, + glk_stream_get_rock : glk_stream_get_rock, + glk_stream_open_file : glk_stream_open_file, + glk_stream_open_memory : glk_stream_open_memory, + glk_stream_close : glk_stream_close, + glk_stream_set_position : glk_stream_set_position, + glk_stream_get_position : glk_stream_get_position, + glk_stream_set_current : glk_stream_set_current, + glk_stream_get_current : glk_stream_get_current, + glk_fileref_create_temp : glk_fileref_create_temp, + glk_fileref_create_by_name : glk_fileref_create_by_name, + glk_fileref_create_by_prompt : glk_fileref_create_by_prompt, + glk_fileref_destroy : glk_fileref_destroy, + glk_fileref_iterate : glk_fileref_iterate, + glk_fileref_get_rock : glk_fileref_get_rock, + glk_fileref_delete_file : glk_fileref_delete_file, + glk_fileref_does_file_exist : glk_fileref_does_file_exist, + glk_fileref_create_from_fileref : glk_fileref_create_from_fileref, + glk_put_char : glk_put_char, + glk_put_char_stream : glk_put_char_stream, + glk_put_string : glk_put_string, + glk_put_string_stream : glk_put_string_stream, + glk_put_buffer : glk_put_buffer, + glk_put_buffer_stream : glk_put_buffer_stream, + glk_set_style : glk_set_style, + glk_set_style_stream : glk_set_style_stream, + glk_get_char_stream : glk_get_char_stream, + glk_get_line_stream : glk_get_line_stream, + glk_get_buffer_stream : glk_get_buffer_stream, + glk_char_to_lower : glk_char_to_lower, + glk_char_to_upper : glk_char_to_upper, + glk_stylehint_set : glk_stylehint_set, + glk_stylehint_clear : glk_stylehint_clear, + glk_style_distinguish : glk_style_distinguish, + glk_style_measure : glk_style_measure, + glk_select : glk_select, + glk_select_poll : glk_select_poll, + glk_request_line_event : glk_request_line_event, + glk_cancel_line_event : glk_cancel_line_event, + glk_request_char_event : glk_request_char_event, + glk_cancel_char_event : glk_cancel_char_event, + glk_request_mouse_event : glk_request_mouse_event, + glk_cancel_mouse_event : glk_cancel_mouse_event, + glk_request_timer_events : glk_request_timer_events, + glk_image_get_info : glk_image_get_info, + glk_image_draw : glk_image_draw, + glk_image_draw_scaled : glk_image_draw_scaled, + glk_image_draw_scaled_ext : glk_image_draw_scaled_ext, + glk_window_flow_break : glk_window_flow_break, + glk_window_erase_rect : glk_window_erase_rect, + glk_window_fill_rect : glk_window_fill_rect, + glk_window_set_background_color : glk_window_set_background_color, + glk_schannel_iterate : glk_schannel_iterate, + glk_schannel_get_rock : glk_schannel_get_rock, + glk_schannel_create : glk_schannel_create, + glk_schannel_destroy : glk_schannel_destroy, + glk_schannel_play : glk_schannel_play, + glk_schannel_play_ext : glk_schannel_play_ext, + glk_schannel_stop : glk_schannel_stop, + glk_schannel_set_volume : glk_schannel_set_volume, + glk_schannel_create_ext : glk_schannel_create_ext, + glk_schannel_play_multi : glk_schannel_play_multi, + glk_schannel_pause : glk_schannel_pause, + glk_schannel_unpause : glk_schannel_unpause, + glk_schannel_set_volume_ext : glk_schannel_set_volume_ext, + glk_sound_load_hint : glk_sound_load_hint, + glk_set_hyperlink : glk_set_hyperlink, + glk_set_hyperlink_stream : glk_set_hyperlink_stream, + glk_request_hyperlink_event : glk_request_hyperlink_event, + glk_cancel_hyperlink_event : glk_cancel_hyperlink_event, + glk_buffer_to_lower_case_uni : glk_buffer_to_lower_case_uni, + glk_buffer_to_upper_case_uni : glk_buffer_to_upper_case_uni, + glk_buffer_to_title_case_uni : glk_buffer_to_title_case_uni, + glk_buffer_canon_decompose_uni : glk_buffer_canon_decompose_uni, + glk_buffer_canon_normalize_uni : glk_buffer_canon_normalize_uni, + glk_put_char_uni : glk_put_char_uni, + glk_put_string_uni : glk_put_string_uni, + glk_put_buffer_uni : glk_put_buffer_uni, + glk_put_char_stream_uni : glk_put_char_stream_uni, + glk_put_string_stream_uni : glk_put_string_stream_uni, + glk_put_buffer_stream_uni : glk_put_buffer_stream_uni, + glk_get_char_stream_uni : glk_get_char_stream_uni, + glk_get_buffer_stream_uni : glk_get_buffer_stream_uni, + glk_get_line_stream_uni : glk_get_line_stream_uni, + glk_stream_open_file_uni : glk_stream_open_file_uni, + glk_stream_open_memory_uni : glk_stream_open_memory_uni, + glk_request_char_event_uni : glk_request_char_event_uni, + glk_request_line_event_uni : glk_request_line_event_uni, + glk_set_echo_line_event : glk_set_echo_line_event, + glk_set_terminators_line_event : glk_set_terminators_line_event, + glk_current_time : glk_current_time, + glk_current_simple_time : glk_current_simple_time, + glk_time_to_date_utc : glk_time_to_date_utc, + glk_time_to_date_local : glk_time_to_date_local, + glk_simple_time_to_date_utc : glk_simple_time_to_date_utc, + glk_simple_time_to_date_local : glk_simple_time_to_date_local, + glk_date_to_time_utc : glk_date_to_time_utc, + glk_date_to_time_local : glk_date_to_time_local, + glk_date_to_simple_time_utc : glk_date_to_simple_time_utc, + glk_date_to_simple_time_local : glk_date_to_simple_time_local, + glk_stream_open_resource : glk_stream_open_resource, + glk_stream_open_resource_uni : glk_stream_open_resource_uni +}; + +}; + +/* Glk is an instance of GlkClass, ready to init. */ +var Glk = new GlkClass(); + +// Node-compatible behavior +try { exports.Glk = Glk; exports.GlkClass = GlkClass; } catch (ex) {}; + +/* End of Glk library. */ diff --git a/src/Arcade/Zork/vendor/zvm.d.ts b/src/Arcade/Zork/vendor/zvm.d.ts new file mode 100644 index 0000000000..79581f8f01 --- /dev/null +++ b/src/Arcade/Zork/vendor/zvm.d.ts @@ -0,0 +1,17 @@ +// Type declarations for the verbatim vendored zvm.js (ifvms 1.1.6, MIT). +// Describes only the API surface our glue code uses; wiring verified against +// ifvms 1.1.6 bin/zvm.js. See LICENSE.md and README.md in src/Arcade/Zork. +declare class ZVM { + prepare(storydata: Uint8Array, options: ZVM.Options): void; +} + +declare namespace ZVM { + interface Options { + vm: ZVM; + Glk: unknown; + GlkOte: unknown; + Dialog: unknown; + } +} + +export = ZVM; diff --git a/src/Arcade/Zork/vendor/zvm.js b/src/Arcade/Zork/vendor/zvm.js new file mode 100644 index 0000000000..74dd2187a5 --- /dev/null +++ b/src/Arcade/Zork/vendor/zvm.js @@ -0,0 +1,4312 @@ +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.ZVM = f()}})(function(){var define,module,exports;return (function(){function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o retain immediate returns if optimisations are disabled +Variable = Operand.subClass({ + // Get a value + toString: function() + { + var variable = this.v; + + // Indirect + if ( this.indirect ) + { + return 'e.indirect(' + variable + ')'; + } + + // Stack + if ( variable === 0 ) + { + // If we've been passed a value we're setting a variable + return 's[--e.sp]'; + } + // Locals + if ( --variable < 15 ) + { + return 'l[' + variable + ']'; + } + // Globals + return 'e.m.getUint16(' + ( this.e.globals + ( variable - 15 ) * 2 ) + ')'; + }, + + // Store a value + store: function( value ) + { + var variable = this.v; + + // Indirect variable + if ( this.indirect ) + { + return 'e.indirect(' + variable + ',' + value + ')'; + } + + // BrancherStorers need the value + if ( this.returnval ) + { + return 'e.variable(' + variable + ',' + value + ')'; + } + + // Stack + if ( variable === 0 ) + { + // If we've been passed a value we're setting a variable + return 't=' + value + ';s[e.sp++]=t'; + } + // Locals + if ( --variable < 15 ) + { + return 'l[' + variable + ']=' + value; + } + // Globals + return 'e.ram.setUint16(' + ( this.e.globals + ( variable - 15 ) * 2 ) + ',' + value + ')'; + }, + + // Convert an Operand into a signed operand + U2S: function() + { + return 'e.U2S(' + this + ')'; + }, +}), + +// Generic opcode +// .func() must be set, which returns what .write() will actually return; it is passed the operands as its arguments +Opcode = Class.subClass({ + init: function( engine, context, code, pc, next, operands ) + { + this.e = engine; + this.context = context; + this.code = code; + this.pc = pc; + this.labels = [ this.pc + '/' + this.code ]; + this.next = next; + this.operands = operands; + + // Post-init function (so that they don't all have to call _super) + if ( this.post ) + { + this.post(); + } + }, + + // Write out the opcode, passing .operands to .func(), with a JS comment of the pc/opcode + toString: function() + { + return this.label() + ( this.func ? this.func.apply( this, this.operands ) : '' ); + }, + + // Return a string of the operands separated by commas + args: function( joiner ) + { + return this.operands.join( joiner ); + }, + + // Generate a comment of the pc and code, possibly for more than one opcode + label: function() + { + return '/* ' + this.labels.join() + ' */ '; + }, +}), + +// Stopping opcodes +Stopper = Opcode.subClass({ + stopper: 1, +}), + +// Pausing opcodes (ie, set the pc at the end of the context) +Pauser = Stopper.subClass({ + post: function() + { + this.origfunc = this.func; + this.func = this.newfunc; + }, + + newfunc: function() + { + return 'e.stop=1;e.pc=' + this.next + ';' + this.origfunc.apply( this, arguments ); + }, +}), + +PauserStorer = Pauser.subClass({ + storer: 1, + + post: function() + { + this.storer = this.operands.pop(); + this.origfunc = this.func; + this.func = this.newfunc; + }, +}), + +// Join multiple branchers together with varying logic conditions +BrancherLogic = Class.subClass({ + init: function( ops, code ) + { + this.ops = ops || []; + this.code = code || '||'; + }, + + toString: function() + { + var i = 0, + ops = [], + op; + while ( i < this.ops.length ) + { + op = this.ops[i++]; + // Accept either Opcodes or further BrancherLogics + ops.push( + op.func ? + ( op.iftrue ? '' : '!(' ) + op.func.apply( op, op.operands ) + ( op.iftrue ? '' : ')' ) : + op + ); + } + return ( this.invert ? '(!(' : '(' ) + ops.join( this.code ) + ( this.invert ? '))' : ')' ); + }, +}), + +// Branching opcodes +Brancher = Opcode.subClass({ + // Flag for the disassembler + brancher: 1, + + keyword: 'if', + + // Process the branch result now + post: function() + { + var result, + prev, + + // Calculate the offset + brancher = this.operands.pop(), + offset = brancher[1]; + this.iftrue = brancher[0]; + + // Process the offset + if ( offset === 0 || offset === 1 ) + { + result = 'e.ret(' + offset + ')'; + } + else + { + offset += this.next - 2; + + // Add this target to this context's list + this.context.targets.push( offset ); + result = 'e.pc=' + offset; + } + + this.result = result + ';return'; + this.offset = offset; + this.cond = new BrancherLogic( [this] ); + + // TODO: re-enable + /*if ( this.e.options.debug ) + { + // Stop if we must + if ( debugflags.noidioms ) + { + return; + } + }*/ + + // Compare with previous statement + if ( this.context.ops.length ) + { + prev = this.context.ops.pop(); + // As long as no other opcodes have an offset property we can skip the instanceof check + if ( /* prev instanceof Brancher && */ prev.offset === offset ) + { + // Goes to same offset so reuse the Brancher arrays + this.cond.ops.unshift( prev.cond ); + this.labels = prev.labels; + this.labels.push( this.pc + '/' + this.code ); + } + else + { + this.context.ops.push( prev ); + } + } + }, + + // Write out the brancher + toString: function() + { + var result = this.result; + + // Account for Contexts + if ( result instanceof Context ) + { + // Update the context to be a child of this context + if ( this.e.options.debug ) + { + result.context = this.context; + } + + result = result + ( result.stopper ? '; return' : '' ); + + // Extra line breaks for multi-op results + if ( this.result.ops.length > 1 ) + { + result = '\n' + result + '\n'; + if ( this.e.options.debug ) + { + result += this.context.spacer; + } + } + } + + // Print out a label for all included branches and the branch itself + return this.label() + this.keyword + this.cond + ' {' + result + '}'; + }, +}), + +// Brancher + Storer +BrancherStorer = Brancher.subClass({ + storer: 1, + + // Set aside the storer operand + post: function() + { + BrancherStorer.super.post.call( this ); + this.storer = this.operands.pop(); + this.storer.returnval = 1; + + // Replace the func + this.origfunc = this.func; + this.func = this.newfunc; + }, + + newfunc: function() + { + return this.storer.store( this.origfunc.apply( this, arguments ) ); + }, +}), + +// Storing opcodes +Storer = Opcode.subClass({ + // Flag for the disassembler + storer: 1, + + // Set aside the storer operand + post: function() + { + this.storer = this.operands.pop(); + }, + + // Write out the opcode, passing it to the storer (if there still is one) + toString: function() + { + var data = Storer.super.toString.call( this ); + + // If we still have a storer operand, use it + // Otherwise (if it's been removed due to optimisations) just return func() + return this.storer ? this.storer.store( data ) : data; + }, +}), + +// Routine calling opcodes +Caller = Stopper.subClass({ + // Fake a result variable + result: { v: -1 }, + + // Write out the opcode + toString: function() + { + // TODO: Debug: include label if possible + return this.label() + 'e.call(' + this.operands.shift() + ',' + this.result.v + ',' + this.next + ',[' + this.args() + '])'; + }, +}), + +// Routine calling opcodes, storing the result +CallerStorer = Caller.subClass({ + // Flag for the disassembler + storer: 1, + + post: function() + { + // We can't let the storer be optimised away here + this.result = this.operands.pop(); + }, +}), + +// A generic context (a routine, loop body etc) +Context = Class.subClass({ + init: function( engine, pc ) + { + this.e = engine; + this.pc = pc; + this.pre = []; + this.ops = []; + this.post = []; + this.targets = []; // Branch targets + if ( engine.options.debug ) + { + this.spacer = ''; + } + }, + + toString: function() + { + if ( this.e.options.debug ) + { + // Indent the spacer further if needed + if ( this.context ) + { + this.spacer = this.context.spacer + ' '; + } + // DEBUG: Pretty print! + return this.pre.join( '' ) + ( this.ops.length > 1 ? this.spacer : '' ) + this.ops.join( ';\n' + this.spacer ) + this.post.join( '' ); + + } + else + { + // Return the code + return this.pre.join( '' ) + this.ops.join( ';' ) + this.post.join( '' ); + } + }, +}), + +// A routine body +RoutineContext = Context.subClass({ + toString: function() + { + // TODO: Debug: If we have routine names, find this one's name + + // Add in some extra vars and return + this.pre.unshift( 'var l=e.l,s=e.s,t=0;\n' ); + return RoutineContext.super.toString.call( this ); + }, +}); + +// Opcode builder +// Easily build a new opcode from a class +function opcode_builder( Class, func, flags ) +{ + flags = flags || {}; + if ( func ) + { + /*if ( func.pop ) + { + flags.str = func; + flags.func = common_func; + } + else + {*/ + flags.func = func; + //} + } + return Class.subClass( flags ); +} + +module.exports = { + Operand: Operand, + Variable: Variable, + Opcode: Opcode, + Stopper: Stopper, + Pauser: Pauser, + PauserStorer: PauserStorer, + BrancherLogic: BrancherLogic, + Brancher: Brancher, + BrancherStorer: BrancherStorer, + Storer: Storer, + Caller: Caller, + CallerStorer: CallerStorer, + Context: Context, + RoutineContext: RoutineContext, + opcode_builder: opcode_builder, +}; + +},{"../common/utils.js":3}],2:[function(require,module,exports){ +/* + +File classes +============ + +Copyright (c) 2016 The ifvms.js team +BSD licenced +http://github.com/curiousdannii/ifvms.js + +*/ + +'use strict'; + +var utils = require( './utils.js' ), +MemoryView = utils.MemoryView, + +// A basic IFF file, to be extended later +// Currently supports buffer data +IFF = utils.Class.subClass({ + init: function( data ) + { + this.type = ''; + this.chunks = []; + + if ( data ) + { + var view = MemoryView( data ), + i = 12, length, chunk_length; + + // Check that it is actually an IFF file + if ( view.getFourCC( 0 ) !== 'FORM' ) + { + throw new Error( 'Not an IFF file' ); + } + + // Parse the file + this.type = view.getFourCC( 8 ); + length = view.getUint32( 4 ) + 8; + + while ( i < length ) + { + chunk_length = view.getUint32( i + 4 ); + + if ( chunk_length < 0 || ( chunk_length + i ) > length ) + { + throw new Error( 'IFF chunk out of range' ); + } + + this.chunks.push({ + type: view.getFourCC( i ), + offset: i, + data: view.getUint8Array( i + 8, chunk_length ), + }); + + i += 8 + chunk_length; + if ( chunk_length % 2 ) + { + i++; + } + } + } + }, + + write: function() + { + // Start with the IFF type + var buffer_len = 12, i = 0, index = 12, + out, chunk; + + // First calculate the required buffer length + while ( i < this.chunks.length ) + { + // Replace typed arrays or dataviews with their buffers + if ( this.chunks[i].data.buffer ) + { + this.chunks[i].data = this.chunks[i].data.buffer; + } + this.chunks[i].length = this.chunks[i].data.byteLength || this.chunks[i].data.length; + buffer_len += 8 + this.chunks[i++].length; + if ( buffer_len % 2 ) + { + buffer_len++; + } + } + + out = MemoryView( buffer_len ); + out.setFourCC( 0, 'FORM' ); + out.setUint32( 4, buffer_len - 8 ); + out.setFourCC( 8, this.type ); + + // Go through the chunks and write them out + i = 0; + while ( i < this.chunks.length ) + { + chunk = this.chunks[i++]; + out.setFourCC( index, chunk.type ); + out.setUint32( index + 4, chunk.length ); + out.setUint8Array( index + 8, chunk.data ); + index += 8 + chunk.length; + if ( index % 2 ) + { + index++; + } + } + + return out.buffer; + }, +}), + +Blorb = IFF.subClass({ + init: function( data ) + { + this.super.init.call( this, data ); + if ( data ) + { + if ( this.type !== 'IFRS' ) + { + throw new Error( 'Not a Blorb file' ); + } + + // Process the RIdx chunk to find the main exec chunk + if ( this.chunks[0].type !== 'RIdx' ) + { + throw new Error( 'Malformed Blorb: chunk 1 is not RIdx' ); + } + var view = MemoryView( this.chunks[0].data ), + i = 4; + while ( i < this.chunks[0].data.length ) + { + if ( view.getFourCC( i ) === 'Exec' && view.getUint32( i + 4 ) === 0 ) + { + this.exec = this.chunks.filter( function( chunk ) + { + return chunk.offset === view.getUint32( i + 8 ); + })[0]; + return; + } + i += 12; + } + } + }, +}), + +Quetzal = IFF.subClass({ + // Parse a Quetzal savefile, or make a blank one + init: function( data ) + { + this.super.init.call( this, data ); + if ( data ) + { + // Check this is a Quetzal savefile + if ( this.type !== 'IFZS' ) + { + throw new Error( 'Not a Quetzal savefile' ); + } + + // Go through the chunks and extract the useful ones + var i = 0, + type, chunk_data, view; + + while ( i < this.chunks.length ) + { + type = this.chunks[i].type; + chunk_data = this.chunks[i++].data; + + // Memory and stack chunks + if ( type === 'CMem' || type === 'UMem' ) + { + this.memory = chunk_data; + this.compressed = ( type === 'CMem' ); + } + else if ( type === 'Stks' ) + { + this.stacks = chunk_data; + } + + // Story file data + else if ( type === 'IFhd' ) + { + view = MemoryView( chunk_data.buffer ); + this.release = view.getUint16( 0 ); + this.serial = view.getUint8Array( 2, 6 ); + // The checksum isn't used, but if we throw it away we can't round-trip + this.checksum = view.getUint16( 8 ); + // The pc is only a Uint24, but there's no function for that, so grab an extra byte and then discard it + this.pc = view.getUint32( 9 ) & 0xFFFFFF; + } + } + } + }, + + // Write out a savefile + write: function() + { + // Reset the IFF type + this.type = 'IFZS'; + + // Format the IFhd chunk correctly + var ifhd = MemoryView( 13 ); + ifhd.setUint16( 0, this.release ); + ifhd.setUint8Array( 2, this.serial ); + ifhd.setUint32( 9, this.pc ); + ifhd.setUint16( 8, this.checksum ); + + // Add the chunks + this.chunks = [ + { type: 'IFhd', data: ifhd }, + { type: ( this.compressed ? 'CMem' : 'UMem' ), data: this.memory }, + { type: 'Stks', data: this.stacks }, + ]; + + // Return the byte array + return this.super.write.call( this ); + }, +}); + +// Inspect a file and identify its format and version number +function identify( buffer ) +{ + var view = MemoryView( buffer ), + blorb, + format, + version; + + // Blorb + if ( view.getFourCC( 0 ) === 'FORM' && view.getFourCC( 8 ) === 'IFRS' ) + { + blorb = new Blorb( buffer ); + if ( blorb.exec ) + { + format = blorb.exec.type; + buffer = blorb.exec.data; + if ( format === 'GLUL' ) + { + view = MemoryView( buffer ); + version = view.getUint32( 4 ); + } + if ( format === 'ZCOD' ) + { + version = buffer[0]; + } + } + } + // Glulx + else if ( view.getFourCC( 0 ) === 'Glul' ) + { + format = 'GLUL'; + version = view.getUint32( 4 ); + } + // Z-Code + else + { + version = view.getUint8( 0 ); + if ( version > 0 && version < 9 ) + { + format = 'ZCOD'; + } + } + + if ( format && version ) + { + return { + format: format, + version: version, + data: buffer, + }; + } +} + +module.exports = { + IFF: IFF, + Blorb: Blorb, + Quetzal: Quetzal, + identify: identify, +}; + +},{"./utils.js":3}],3:[function(require,module,exports){ +/* + +Common untility functions +========================= + +Copyright (c) 2016 The ifvms.js team +BSD licenced +http://github.com/curiousdannii/ifvms.js + +*/ + +'use strict'; + +// Utility to extend objects +function extend() +{ + var old = arguments[0], i = 1, add, name; + while ( i < arguments.length ) + { + add = arguments[i++]; + for ( name in add ) + { + old[name] = add[name]; + } + } + return old; +} + +// Simple classes +// Inspired by John Resig's class implementation +// http://ejohn.org/blog/simple-javascript-inheritance/ + +function Class() +{} + +Class.subClass = function( props ) +{ + function newClass() + { + if ( this.init ) + { + this.init.apply( this, arguments ); + } + } + newClass.prototype = extend( Object.create( this.prototype ), props ); + newClass.subClass = this.subClass; + newClass.super = newClass.prototype.super = this.prototype; + return newClass; +}; + +// An enhanced DataView +// Accepts an ArrayBuffer, another view (MemoryView / DataView / TypedArray), or a length number +function MemoryView( buffer, byteOffset, byteLength ) +{ + // Length number + if ( typeof buffer === 'number' ) + { + buffer = new ArrayBuffer( buffer ); + } + + // MemoryView / DataView / TypedArray + else if ( buffer.buffer ) + { + // If unspecified, byteOffset defaults at the beginning of the given view. Note + // that We will adjust 'byteOffset' after using the initial value to calculate + // the default byteLength below. + byteOffset |= 0; + + // A view may be a subset of a potentially larger array buffer. Before extracting + // the underlying buffer, map the given 'byteLength' and byteOffset' to the underlying + // array buffer from which we will construct the DataView. + + // A specified 'byteLength' does not need to be adjusted, but if no byteLength was + // given, we need to ensure that the resulting MemoryView does not extend past the + // end of the typed array (see above). + if ( typeof byteLength === 'undefined' ) + { + byteLength = buffer.byteLength - byteOffset; + } + + // Map the 'byteOffset', which is currently relative to the typed array, to the same + // location in the underlying array buffer. + byteOffset += buffer.byteOffset; + + // Finally, extract the underlying array buffer. + buffer = buffer.buffer; + } + // Else already an ArrayBuffer. No adjustments to 'byteOffset'/'byteLength' necessary. + + return extend( new DataView( buffer, byteOffset, byteLength ), { + getUint8Array: function( start, length ) + { + // Note that start/length are non-optional, so we only need to adjust the start to + // the byteOffset of the view. (See MemoryView ctor comments.) + start += this.byteOffset; + + return new Uint8Array( this.buffer.slice( start, start + length ) ); + }, + getUint16Array: function( start, length ) + { + // Note that start/length are non-optional, so we only need to adjust the start to + // the byteOffset of the view. (See MemoryView ctor comments.) + start += this.byteOffset; + + // We cannot simply return a Uint16Array as most systems are little-endian + return Uint8toUint16Array( new Uint8Array( this.buffer, start, length * 2 ) ); + }, + setUint8Array: function( start, data ) + { + if ( data instanceof ArrayBuffer ) + { + data = new Uint8Array( data ); + } + ( new Uint8Array( this.buffer, this.byteOffset, this.byteLength ) ).set( data, start ); + }, + //setBuffer16 NOTE: if we implement this we cannot simply set a Uint16Array as most systems are little-endian + + // For use with IFF files + getFourCC: function( index ) + { + return String.fromCharCode( this.getUint8( index ), this.getUint8( index + 1 ), this.getUint8( index + 2 ), this.getUint8( index + 3 ) ); + }, + setFourCC: function( index, text ) + { + this.setUint8( index, text.charCodeAt( 0 ) ); + this.setUint8( index + 1, text.charCodeAt( 1 ) ); + this.setUint8( index + 2, text.charCodeAt( 2 ) ); + this.setUint8( index + 3, text.charCodeAt( 3 ) ); + }, + } ); +} + +// Utilities for 16-bit signed arithmetic +function U2S16( value ) +{ + return value << 16 >> 16; +} +function S2U16 ( value ) +{ + return value & 0xFFFF; +} + +// Utility to convert from byte arrays to word arrays +function Uint8toUint16Array( array ) +{ + var i = 0, l = array.length, + result = new Uint16Array( l / 2 ); + while ( i < l ) + { + result[i / 2] = array[i++] << 8 | array[i++]; + } + return result; +} + +module.exports = { + extend: extend, + Class: Class, + MemoryView: MemoryView, + U2S16: U2S16, + S2U16: S2U16, + Uint8toUint16Array: Uint8toUint16Array, +}; +},{}],4:[function(require,module,exports){ +/* + +ZVM - the ifvms.js Z-Machine (versions 3-5, 8) +============================================== + +Copyright (c) 2017 The ifvms.js team +MIT licenced +https://github.com/curiousdannii/ifvms.js + +*/ + +/* + +This file is the public API of ZVM, which is based on the API of Quixe: +https://github.com/erkyrath/quixe/wiki/Quixe-Without-GlkOte#quixes-api + +ZVM willfully ignores the standard in these ways: + Non-buffered output is not supported + Saving tables is not supported (yet?) + No interpreter number or version is set + +Any other non-standard behaviour should be considered a bug + +*/ + +'use strict'; + +var utils = require( './common/utils.js' ), +file = require( './common/file.js' ), + +default_options = { + stack_len: 100 * 1000, + undo_len: 1000 * 1000, +}, + +api = { + + init: function() + { + // Create this here so that it won't be cleared on restart + this.jit = {}; + + // The Quixe API expects the start function to be named init + this.init = this.start; + }, + + prepare: function( storydata, options ) + { + // If we are not given a glk option then we cannot continue + if ( !options.Glk ) + { + throw new Error( 'A reference to Glk is required' ); + } + this.Glk = options.Glk; + this.data = storydata; + this.options = utils.extend( {}, default_options, options ); + }, + + start: function() + { + var Glk = this.Glk, + data; + try + { + // Identify the format and version number of the data file we were given + data = file.identify( this.data ); + delete this.data; + if ( !data || data.format !== 'ZCOD' ) + { + throw new Error( 'This is not a Z-Code file' ); + } + if ( [ 3, 4, 5, 8 ].indexOf( data.version ) < 0 ) + { + throw new Error( 'Unsupported Z-Machine version: ' + data.version ); + } + + // Load the storyfile we are given into our MemoryView (an enhanced DataView) + this.m = utils.MemoryView( data.data ); + + // Make a seperate MemoryView for the ram, and store the original ram + this.staticmem = this.m.getUint16( 0x0E ); + this.ram = utils.MemoryView( this.m, 0, this.staticmem ); + this.origram = this.m.getUint8Array( 0, this.staticmem ); + + // Cache the game signature + let signature = '' + let i = 0 + while ( i < 0x1E ) + { + signature += ( this.origram[i] < 0x10 ? '0' : '' ) + this.origram[i++].toString( 16 ) + } + this.signature = signature + + // Handle loading and clearing autosaves + let autorestored + const Dialog = this.options.Dialog + if ( Dialog ) + { + if ( this.options.clear_vm_autosave ) + { + Dialog.autosave_write( signature, null ) + } + else if ( this.options.do_vm_autosave ) + { + try + { + const snapshot = Dialog.autosave_read( signature ) + if ( snapshot ) + { + this.do_autorestore( snapshot ) + autorestored = 1 + } + } + catch (ex) + { + this.log('Autorestore failed, deleting it: ' + ex) + Dialog.autosave_write( signature, null ) + } + } + } + + // Initiate the engine, run, and wait for our first Glk event + if ( !autorestored ) + { + this.restart(); + this.run(); + } + if ( !this.quit ) + { + this.glk_event = new Glk.RefStruct(); + if ( !this.glk_blocking_call ) + { + Glk.glk_select( this.glk_event ); + } + else + { + this.glk_event.push_field( this.glk_blocking_call ); + } + } + Glk.update() + } + catch ( e ) + { + Glk.fatal_error( e ); + console.log( e ); + } + }, + + resume: function( resumearg ) + { + var Glk = this.Glk, + glk_event = this.glk_event, + event_type, + run; + + try + { + event_type = glk_event.get_field( 0 ); + + // Process the event + if ( event_type === 2 ) + { + this.handle_char_input( glk_event.get_field( 2 ) ); + run = 1; + } + if ( event_type === 3 ) + { + this.handle_line_input( glk_event.get_field( 2 ), glk_event.get_field( 3 ) ); + run = 1; + } + + // Arrange events + if ( event_type === 5 ) + { + this.update_screen_size() + } + + // glk_fileref_create_by_prompt handler + if ( event_type === 'fileref_create_by_prompt' ) + { + run = this.handle_create_fileref( resumearg ); + } + + this.glk_blocking_call = null; + if ( run ) + { + this.run(); + } + + // Wait for another event + if ( !this.quit ) + { + this.glk_event = new Glk.RefStruct(); + if ( !this.glk_blocking_call ) + { + Glk.glk_select( this.glk_event ); + } + else + { + this.glk_event.push_field( this.glk_blocking_call ); + } + } + Glk.update() + } + catch ( e ) + { + Glk.fatal_error( e ); + console.log( e ); + } + }, + + get_signature: function() + { + return this.signature + }, + + // Run + run: function() + { + var pc, + result; + + // Stop when ordered to + this.stop = 0; + while ( !this.stop ) + { + pc = this.pc; + if ( !this.jit[pc] ) + { + this.compile(); + } + result = this.jit[pc]( this ); + + // Return from a VM func if the JIT function returned a result + if ( !isNaN( result ) ) + { + this.ret( result ); + } + } + }, + + // Compile a JIT routine + compile: function() + { + var context = this.disassemble(); + + // Compile the routine with new Function() + this.jit[context.pc] = new Function( 'e', '' + context ); + + if ( context.pc < this.staticmem ) + { + this.log( 'Caching a JIT function in dynamic memory: ' + context.pc ); + } + }, + +}, + +VM = utils.Class.subClass( utils.extend( + api, + require( './zvm/runtime.js' ), + require( './zvm/text.js' ), + require( './zvm/io.js' ), + require( './zvm/disassembler.js' ) +) ); + +module.exports = VM; + +},{"./common/file.js":2,"./common/utils.js":3,"./zvm/disassembler.js":5,"./zvm/io.js":6,"./zvm/runtime.js":8,"./zvm/text.js":9}],5:[function(require,module,exports){ +/* + +Z-Machine disassembler - disassembles zcode into an AST +======================================================= + +Copyright (c) 2011 The ifvms.js team +BSD licenced +http://github.com/curiousdannii/ifvms.js + +*/ + +/* + +Note: + Nothing is done to check whether an instruction actually has a valid number of operands. Extras will usually be ignored while missing operands may throw errors at either the code building stage or when the JIT code is called. + +TODO: + If we diassessemble part of what we already have before, can we just copy/slice the context? + +*/ + +var AST = require( '../common/ast.js' ); + +module.exports.disassemble = function() +{ + var pc, offset, // Set in the loop below + memory = this.m, + opcodes = this.opcodes, + temp, + code, + opcode_class, + operands_type, // The types of the operands, or -1 for var instructions + operands, + + // Create the context for this code fragment + context = new AST.RoutineContext( this, this.pc ); + + // Utility function to unpack the variable form operand types byte + function get_var_operand_types( operands_byte, operands_type ) + { + for ( var i = 0; i < 4; i++ ) + { + operands_type.push( (operands_byte & 0xC0) >> 6 ); + operands_byte <<= 2; + } + } + + // Set the context's root context to be itself, and add it to the list of subcontexts + //context.root = context; + //context.contexts[0] = context; + + // Run through until we can no more + while ( 1 ) + { + // This instruction + offset = pc = this.pc; + code = memory.getUint8( pc++ ); + + // Extended instructions + if ( code === 190 ) + { + operands_type = -1; + code = memory.getUint8( pc++ ) + 1000; + } + + else if ( code & 0x80 ) + { + // Variable form instructions + if ( code & 0x40 ) + { + operands_type = -1; + // 2OP instruction with VAR parameters + if ( !(code & 0x20) ) + { + code &= 0x1F; + } + } + + // Short form instructions + else + { + operands_type = [ (code & 0x30) >> 4 ]; + // Clear the operand type if 1OP, keep for 0OPs + if ( operands_type[0] < 3 ) + { + code &= 0xCF; + } + } + } + + // Long form instructions + else + { + operands_type = [ code & 0x40 ? 2 : 1, code & 0x20 ? 2 : 1 ]; + code &= 0x1F; + } + + // Check for missing opcodes + if ( !opcodes[code] ) + { + this.log( '' + context ); + this.stop = 1; + throw new Error( 'Unknown opcode #' + code + ' at pc=' + offset ); + } + + // Variable for quicker access to the opcode flags + opcode_class = opcodes[code].prototype; + + // Variable form operand types + if ( operands_type === -1 ) + { + operands_type = []; + get_var_operand_types( memory.getUint8(pc++), operands_type ); + + // VAR_LONG opcodes have two operand type bytes + if ( code === 236 || code === 250 ) + { + get_var_operand_types( memory.getUint8(pc++), operands_type ); + } + } + + // Load the operands + operands = []; + temp = 0; + while ( temp < operands_type.length ) + { + // Large constant + if ( operands_type[temp] === 0 ) + { + operands.push( new AST.Operand( this, memory.getUint16(pc) ) ); + pc += 2; + } + + // Small constant + if ( operands_type[temp] === 1 ) + { + operands.push( new AST.Operand( this, memory.getUint8(pc++) ) ); + } + + // Variable operand + if ( operands_type[temp++] === 2 ) + { + operands.push( new AST.Variable( this, memory.getUint8(pc++) ) ); + } + } + + // Check for a store variable + if ( opcode_class.storer ) + { + operands.push( new AST.Variable( this, memory.getUint8(pc++) ) ); + } + + // Check for a branch address + // If we don't calculate the offset now we won't be able to tell the difference between 0x40 and 0x0040 + if ( opcode_class.brancher ) + { + temp = memory.getUint8( pc++ ); + operands.push( [ + temp & 0x80, // iftrue + temp & 0x40 ? + // single byte address + temp & 0x3F : + // word address, but first get the second byte of it + ( temp << 8 | memory.getUint8( pc++ ) ) << 18 >> 18, + ] ); + } + + // Check for a text literal + if ( opcode_class.printer ) + { + // Just use the address as an operand, the text will be decoded at run time + operands.push( pc ); + + // Continue until we reach the stop bit + // (or the end of the file, which will stop memory access errors, even though it must be a malformed storyfile) + while ( pc < this.eof ) + { + temp = memory.getUint8( pc ); + pc += 2; + + // Stop bit + if ( temp & 0x80 ) + { + break; + } + } + } + + // Update the engine's pc + this.pc = pc; + + // Create the instruction + context.ops.push( new opcodes[code]( this, context, code, offset, pc, operands ) ); + + // Check for the end of a large if block + temp = 0; + /*if ( context.targets.indexOf( pc ) >= 0 ) + { + if ( DEBUG ) + { + // Skip if we must + if ( !debugflags.noidioms ) + { + temp = idiom_if_block( context, pc ); + } + } + else + { + temp = idiom_if_block( context, pc ); + } + }*/ + + // We can't go any further if we have a final stopper :( + if ( opcode_class.stopper && !temp ) + { + break; + } + } + + return context; +}; + +},{"../common/ast.js":1}],6:[function(require,module,exports){ +/* + +Z-Machine IO +============ + +Copyright (c) 2020 The ifvms.js team +MIT licenced +https://github.com/curiousdannii/ifvms.js + +*/ + +'use strict'; + +/* + +TODO: + + - pre-existing line input + - timed input + - mouse input + - write colours into header + +*/ + +const utils = require('../common/utils.js') +const U2S = utils.U2S16 +//S2U = utils.S2U16 + +// Glulx key codes accepted by the Z-Machine +const ZSCII_keyCodes = (function() +{ + var codes = { + 0xfffffff9: 8, // delete/backspace + 0xfffffffa: 13, // enter + 0xfffffff8: 27, // escape + 0xfffffffc: 129, // up + 0xfffffffb: 130, // down + 0xfffffffe: 131, // left + 0xfffffffd: 132, // right + 0xfffffff3: 146, // End / key pad 1 + 0xfffffff5: 148, // PgDn / key pad 3 + 0xfffffff4: 152, // Home / key pad 7 + 0xfffffff6: 154, // PgUp / key pad 9 + }, + i = 0; + while ( i < 12 ) + { + codes[ 0xffffffef - i ] = 133 + i++; // function keys + } + return codes; +})() + +// Style mappings +// The index bits are (lowest to highest): mono, italic, bold +const style_mappings = [0, 2, 1, 10, 4, 9, 5, 6] + +// Convert a 15 bit colour to RGB +function convert_true_colour(colour) +{ + const from5to8 = [0, 8, 16, 25, 33, 41, 49, 58, 66, 74, 82, 90, 99, 107, 115, 123, 132, + 140, 148, 156, 165, 173, 181, 189, 197, 206, 214, 222, 230, 239, 247, 255] + + // Stretch the five bits per colour out to 8 bits + return (from5to8[colour & 0x1F] << 16) | (from5to8[(colour & 0x03E0) >> 5] << 8) | (from5to8[(colour & 0x7C00) >> 10]) +} + +// The standard 15 bit colour values +const zcolours = [ + 0xFFFE, // Current + 0xFFFF, // Default + 0x0000, // Black + 0x001D, // Red + 0x0340, // Green + 0x03BD, // Yellow + 0x59A0, // Blue + 0x7C1F, // Magenta + 0x77A0, // Cyan + 0x7FFF, // White + 0x5AD6, // Light grey + 0x4631, // Medium grey + 0x2D6B, // Dark grey +] + +module.exports = { + + init_io: function() + { + this.io = { + reverse: 0, + bold: 0, + italic: 0, + bg: -1, + fg: -1, + + // A variable for whether we are outputing in a monospaced font. If non-zero then we are + // Bit 0 is for @set_style, bit 1 for the header, and bit 2 for @set_font + mono: this.m.getUint8( 0x11 ) & 0x02, + + // A variable for checking whether the transcript bit has been changed + transcript: this.m.getUint8( 0x11 ) & 0x01, + + // Index 0 is input stream 1, the output streams follow + streams: [ 0, 1, {}, [], {} ], + + currentwin: 0, + + // Use Zarf's algorithm for the upper window + // http://eblong.com/zarf/glk/quote-box.html + // Implemented in fix_upper_window() and split_window() + height: 0, // What the VM thinks the height is + glkheight: 0, // Actual height of the Glk window + maxheight: 0, // Height including quote boxes etc + seenheight: 0, // Last height the player saw + width: 0, + row: 0, + col: 0, + }; + + //this.process_colours(); + + // Construct the windows if they do not already exist + this.open_windows() + }, + + erase_line: function( value ) + { + if ( value === 1 ) + { + var io = this.io, + row = io.row, + col = io.col; + this._print( Array( io.width - io.col + 1 ).join( ' ' ) ); + this.set_cursor( row, col ); + } + }, + + erase_window: function(window) + { + if (window < 1) + { + this.Glk.glk_window_clear(this.mainwin) + if (this.io.bg >= 0) + { + this.Glk.glk_stylehint_set(3, 0, 8, this.io.bg) + } + else if (this.io.bg === -1) + { + this.Glk.glk_stylehint_clear(3, 0, 8) + } + } + if (window !== 0) + { + if (window === -1) + { + this.split_window(0) + } + if (this.upperwin) + { + this.Glk.glk_window_clear(this.upperwin) + this.set_cursor(0, 0) + } + } + }, + + fileref_create_by_prompt: function( data ) + { + if ( typeof data.run === 'undefined' ) + { + data.run = 1; + } + this.fileref_data = data; + this.glk_blocking_call = 'fileref_create_by_prompt'; + this.Glk.glk_fileref_create_by_prompt( data.usage, data.mode, data.rock || 0 ); + }, + + // Fix the upper window height before an input event + fix_upper_window: function() + { + var Glk = this.Glk, + io = this.io; + + // If we have seen the entire window, shrink it to what it should be + if (io.seenheight >= io.maxheight) + { + io.maxheight = io.height; + } + if ( this.upperwin ) + { + if ( io.maxheight === 0 ) + { + Glk.glk_window_close( this.upperwin ); + this.upperwin = null; + } + else if (io.maxheight !== io.glkheight) + { + Glk.glk_window_set_arrangement( Glk.glk_window_get_parent( this.upperwin ), 0x12, io.maxheight, null ); + } + io.glkheight = io.maxheight + } + io.seenheight = io.maxheight; + io.maxheight = io.height; + }, + + format: function() + { + this.Glk.glk_set_style(style_mappings[!!this.io.mono | this.io.italic | this.io.bold]) + if (this.Glk.glk_gestalt(0x1100, 0)) + { + this.Glk.garglk_set_reversevideo(this.io.reverse) + } + }, + + get_cursor: function( array ) + { + this.ram.setUint16( array, this.io.row + 1 ); + this.ram.setUint16( array + 2, this.io.col + 1 ); + }, + + // Handle char input + handle_char_input: function( charcode ) + { + var stream4 = this.io.streams[4], + code = ZSCII_keyCodes[ charcode ] || this.reverse_unicode_table[ charcode ] || 63; + this.variable( this.read_data.storer, code ); + + // Echo to the commands log + if ( stream4.mode === 1 ) + { + stream4.cache += code; + } + if ( stream4.mode === 2 ) + { + this.Glk.glk_put_char_stream_uni( stream4.str, code ); + } + }, + + // Handle the result of glk_fileref_create_by_prompt() + handle_create_fileref: function( fref ) + { + var Glk = this.Glk, + data = this.fileref_data, + str; + + if ( fref ) + { + if ( data.unicode ) + { + str = Glk.glk_stream_open_file_uni( fref, data.mode, data.rock || 0 ); + } + else + { + str = Glk.glk_stream_open_file( fref, data.mode, data.rock || 0 ); + } + Glk.glk_fileref_destroy( fref ); + } + if ( data.func === 'restore' || data.func === 'save' ) + { + this.save_restore_handler( str ); + } + if ( data.func === 'input_stream' ) + { + this.io.streams[0] = str; + } + if ( data.func === 'output_stream' ) + { + this.output_stream_handler( str ); + } + + // Signal to resume() to call run() if required + return data.run; + }, + + // Handle line input + handle_line_input: function( len, terminator ) + { + var ram = this.ram, + options = this.read_data, + streams = this.io.streams, + + // Cut the response to len, convert to a lower case string, and then to a ZSCII array + command = String.fromCharCode.apply( null, options.buffer.slice( 0, len ) ) + '\n', + response = this.text_to_zscii( command.slice( 0, -1 ).toLowerCase() ); + + // 7.1.1.1: The response must be echoed, Glk will handle this + + // But we do have to echo to the transcripts + if ( streams[2].mode === 1 ) + { + streams[2].cache += command; + } + if ( streams[2].mode === 2 ) + { + this.Glk.glk_put_jstring_stream( streams[2].str, command ); + } + + if ( streams[4].mode === 1 ) + { + streams[4].cache += command; + } + if ( streams[4].mode === 2 ) + { + this.Glk.glk_put_jstring_stream( streams[4].str, command ); + } + + // Store the response + if ( this.version < 5 ) + { + // Append zero terminator + response.push( 0 ); + + // Store the response in the buffer + ram.setUint8Array( options.bufaddr + 1, response ); + } + else + { + // Store the response length + ram.setUint8( options.bufaddr + 1, len ); + + // Store the response in the buffer + ram.setUint8Array( options.bufaddr + 2, response ); + + // Store the terminator + this.variable( options.storer, isNaN( terminator ) ? 13 : terminator ); + } + + if ( options.parseaddr ) + { + // Tokenise the response + this.tokenise( options.bufaddr, options.parseaddr ); + } + }, + + input_stream: function( stream ) + { + var io = this.io; + if ( stream && !io.streams[0] ) + { + this.fileref_create_by_prompt({ + func: 'input_stream', + mode: 0x02, + rock: 212, + unicode: 1, + usage: 0x103, + }); + } + if ( !stream && io.streams[0] ) + { + this.Glk.glk_stream_close( io.streams[0] ); + io.streams[0] = 0; + } + }, + + // Open windows + open_windows: function() + { + const Glk = this.Glk + + if (!this.mainwin) + { + // We will borrow the general approach of Bocfel to implement the Z-Machine's formatting model in Glk + // https://github.com/garglk/garglk/blob/master/terps/bocfel/screen.c + + // Reset some Glk stylehints just in case + const styles_to_reset = [1, 2, 4, 5, 6, 9, 10] + for (let i = 0; i < 7; i++) + { + // Reset the size, weight, and obliqueness + Glk.glk_stylehint_set(0, styles_to_reset[i], 3, 0) + Glk.glk_stylehint_set(0, styles_to_reset[i], 4, 0) + Glk.glk_stylehint_set(0, styles_to_reset[i], 5, 0) + // And force proportional font + Glk.glk_stylehint_set(0, styles_to_reset[i], 6, 1) + } + + // Now set the style hints we will use + + // Bold will use subheader + Glk.glk_stylehint_set(0, 4, 4, 1) + // Italic will use emphasised + Glk.glk_stylehint_set(0, 1, 5, 1) + // Bold+italic will use alert + Glk.glk_stylehint_set(0, 5, 4, 1) + Glk.glk_stylehint_set(0, 5, 5, 1) + // Fixed will use preformated + Glk.glk_stylehint_set(0, 2, 6, 0) + // Bold+fixed will use user1 + Glk.glk_stylehint_set(0, 9, 4, 1) + Glk.glk_stylehint_set(0, 9, 6, 0) + // Italic+fixed will use user2 + Glk.glk_stylehint_set(0, 10, 5, 1) + Glk.glk_stylehint_set(0, 10, 6, 0) + // Bold+italic+fixed will use note + Glk.glk_stylehint_set(0, 6, 4, 1) + Glk.glk_stylehint_set(0, 6, 5, 1) + Glk.glk_stylehint_set(0, 6, 6, 0) + + this.mainwin = Glk.glk_window_open(0, 0, 0, 3, 201) + Glk.glk_set_window(this.mainwin) + if (this.version3) + { + this.statuswin = Glk.glk_window_open(this.mainwin, 0x12, 1, 4, 202) + if (this.statuswin && this.Glk.glk_gestalt(0x1100, 0)) + { + Glk.garglk_set_reversevideo_stream(Glk.glk_window_get_stream(this.statuswin), 1) + } + } + } + else + { + // Clean up after restarting + Glk.glk_stylehint_clear(0, 0, 8) + if (this.Glk.glk_gestalt(0x1100, 0)) + { + Glk.garglk_set_zcolors_stream(this.mainwin.str, this.io.fg, this.io.bg) + } + Glk.glk_window_clear(this.mainwin) + if (this.upperwin) + { + Glk.glk_window_close(this.upperwin) + this.upperwin = null + } + } + }, + + // Manage output streams + output_stream: function( stream, addr, called_from_print ) + { + var ram = this.ram, + streams = this.io.streams, + data, text; + stream = U2S( stream ); + + // The screen + if ( stream === 1 ) + { + streams[1] = 1; + } + if ( stream === -1 ) + { + streams[1] = 0; + } + + // Transcript + if ( stream === 2 && !streams[2].mode ) + { + this.fileref_create_by_prompt({ + func: 'output_stream', + mode: 0x05, + rock: 210, + run: !called_from_print, + str: 2, + unicode: 1, + usage: 0x102, + }); + streams[2].cache = ''; + streams[2].mode = 1; + if ( !called_from_print ) + { + this.stop = 1; + } + } + if ( stream === -2 ) + { + ram.setUint8( 0x11, ( ram.getUint8( 0x11 ) & 0xFE ) ); + if ( streams[2].mode === 2 ) + { + this.Glk.glk_stream_close( streams[2].str ); + } + streams[2].mode = this.io.transcript = 0; + } + + // Memory + if ( stream === 3 ) + { + streams[3].unshift( [ addr, '' ] ); + } + if ( stream === -3 ) + { + data = streams[3].shift(); + text = this.text_to_zscii( data[1] ); + ram.setUint16( data[0], text.length ); + ram.setUint8Array( data[0] + 2, text ); + } + + // Command list + if ( stream === 4 && !streams[4].mode ) + { + this.fileref_create_by_prompt({ + func: 'output_stream', + mode: 0x05, + rock: 211, + str: 4, + unicode: 1, + usage: 0x103, + }); + streams[4].cache = ''; + streams[4].mode = 1; + this.stop = 1; + } + if ( stream === -4 ) + { + if ( streams[4].mode === 2 ) + { + this.Glk.glk_stream_close( streams[4].str ); + } + streams[4].mode = 0; + } + }, + + output_stream_handler: function( str ) + { + var ram = this.ram, + streams = this.io.streams, + data = this.fileref_data; + + if ( data.str === 2 ) + { + ram.setUint8( 0x11, ( ram.getUint8( 0x11 ) & 0xFE ) | ( str ? 1 : 0 ) ); + if ( str ) + { + streams[2].mode = 2; + streams[2].str = str; + this.io.transcript = 1; + if ( streams[2].cache ) + { + this.Glk.glk_put_jstring_stream( streams[2].str, streams[2].cache ); + } + } + else + { + streams[2].mode = this.io.transcript = 0; + } + } + + if ( data.str === 4 ) + { + if ( str ) + { + streams[4].mode = 2; + streams[4].str = str; + if ( streams[4].cache ) + { + this.Glk.glk_put_jstring_stream( streams[4].str, streams[4].cache ); + } + } + else + { + streams[4].mode = 0; + } + } + }, + + // Print text! + _print: function( text ) + { + var Glk = this.Glk, + io = this.io, + i = 0; + + // Stream 3 gets the text first + if ( io.streams[3].length ) + { + io.streams[3][0][1] += text; + } + else + { + // Convert CR into LF + text = text.replace( /\r/g, '\n' ); + + // Check the transcript bit + // Because it might need to prompt for a file name, we return here, and will print again in the handler + if ( ( this.m.getUint8( 0x11 ) & 0x01 ) !== io.transcript ) + { + this.output_stream( io.transcript ? -2 : 2, 0, 1 ); + } + + // Check if the monospace font bit has changed + // Unfortunately, even now Inform changes this bit for the font statement, even though the 1.1 standard depreciated it :( + if ( ( this.m.getUint8( 0x11 ) & 0x02 ) !== ( io.mono & 0x02 ) ) + { + io.mono ^= 0x02; + this.format(); + } + + // For the upper window we print each character individually so that we can track the cursor position + if ( io.currentwin && this.upperwin ) + { + // Don't automatically increase the size of the window + // If we confirm that games do need this then we can implement it later + while ( i < text.length && io.row < io.height ) + { + Glk.glk_put_jstring( text[i++] ); + io.col++; + if ( io.col === io.width ) + { + io.col = 0; + io.row++; + } + } + } + else if ( !io.currentwin ) + { + if ( io.streams[1] ) + { + Glk.glk_put_jstring( text ); + } + // Transcript + if ( io.streams[2].mode === 1 ) + { + io.streams[2].cache += text; + } + if ( io.streams[2].mode === 2 ) + { + Glk.glk_put_jstring_stream( io.streams[2].str, text ); + } + } + } + }, + + // Print many things + print: function( type, val ) + { + var proptable, result; + + // Number + if ( type === 0 ) + { + result = val; + } + // Unicode + if ( type === 1 ) + { + result = String.fromCharCode( val ); + } + // Text from address + if ( type === 2 ) + { + result = this.jit[ val ] || this.decode( val ); + } + // Object + if ( type === 3 ) + { + proptable = this.m.getUint16( this.objects + ( this.version3 ? 9 : 14 ) * val + ( this.version3 ? 7 : 12 ) ); + result = this.decode( proptable + 1, this.m.getUint8( proptable ) * 2 ); + } + // ZSCII + if ( type === 4 ) + { + if ( !this.unicode_table[ val ] ) + { + return; + } + result = this.unicode_table[ val ]; + } + this._print( '' + result ); + }, + + print_table: function( zscii, width, height, skip ) + { + height = height || 1; + skip = skip || 0; + var i = 0; + while ( i++ < height ) + { + this._print( this.zscii_to_text( this.m.getUint8Array( zscii, width ) ) + ( i < height ? '\r' : '' ) ); + zscii += width + skip; + } + }, + + // Process CSS default colours + /*process_colours: function() + { + // Convert RGB to a Z-Machine true colour + // RGB is a css colour code. rgb(), #000000 and #000 formats are supported. + function convert_RGB( code ) + { + var round = Math.round, + data = /(\d+),\s*(\d+),\s*(\d+)|#(\w{1,2})(\w{1,2})(\w{1,2})/.exec( code ), + result; + + // Nice rgb() code + if ( data[1] ) + { + result = [ data[1], data[2], data[3] ]; + } + else + { + // Messy CSS colour code + result = [ parseInt( data[4], 16 ), parseInt( data[5], 16 ), parseInt( data[6], 16 ) ]; + // Stretch out compact #000 codes to their full size + if ( code.length === 4 ) + { + result = [ result[0] << 4 | result[0], result[1] << 4 | result[1], result[2] << 4 | result[2] ]; + } + } + + // Convert to a 15bit colour + return round( result[2] / 8.226 ) << 10 | round( result[1] / 8.226 ) << 5 | round( result[0] / 8.226 ); + } + + // Standard colours + var colours = [ + 0xFFFE, // Current + 0xFFFF, // Default + 0x0000, // Black + 0x001D, // Red + 0x0340, // Green + 0x03BD, // Yellow + 0x59A0, // Blue + 0x7C1F, // Magenta + 0x77A0, // Cyan + 0x7FFF, // White + 0x5AD6, // Light grey + 0x4631, // Medium grey + 0x2D6B, // Dark grey + ], + + // Start with CSS colours provided by the runner + fg_css = this.options.fgcolour, + bg_css = this.options.bgcolour, + // Convert to true colour for storing in the header + fg_true = fg_css ? convert_RGB( fg_css ) : 0xFFFF, + bg_true = bg_css ? convert_RGB( bg_css ) : 0xFFFF, + // Search the list of standard colours + fg = colours.indexOf( fg_true ), + bg = colours.indexOf( bg_true ); + // ZVMUI must have colours for reversing text, even if we don't write them to the header + // So use the given colours or assume black on white + if ( fg < 2 ) + { + fg = fg_css || 2; + } + if ( bg < 2 ) + { + bg = bg_css || 9; + } + + utils.extend( this.options, { + fg: fg, + bg: bg, + fg_true: fg_true, + bg_true: bg_true, + }); + },*/ + + // Request line input + read: function( storer, text, parse, time, routine ) + { + var len = this.m.getUint8( text ), + initiallen = 0, + buffer, + input_stream1_len; + + if ( this.version3 ) + { + this.v3_status(); + } + // The spec is badly phrased; the buffer capacity includes the zero terminator + // See https://github.com/DFillmore/Z-Machine-Standard/issues/76 + if (this.version < 5) + { + len-- + } + //else + //{ + //initiallen = this.m.getUint8( text + 1 ); + //} + + buffer = Array( len ); + buffer.fill( 0 ) + this.read_data = { + buffer: buffer, + bufaddr: text, // text-buffer + parseaddr: parse, // parse-buffer + routine: routine, + storer: storer, + time: time, + }; + + // Input stream 1 + if ( this.io.streams[0] ) + { + input_stream1_len = this.Glk.glk_get_line_stream_uni( this.io.streams[0], buffer ); + + // Check for a newline character + if ( buffer[input_stream1_len - 1] === 0x0A ) + { + input_stream1_len--; + } + if ( input_stream1_len ) + { + this._print( String.fromCharCode.apply( null, buffer.slice( 0, input_stream1_len ) ) + '\n' ); + this.handle_line_input( input_stream1_len ); + return this.stop = 0; + } + else + { + this.input_stream( 0 ); + } + } + + // TODO: pre-existing input + this.Glk.glk_request_line_event_uni( this.io.currentwin ? this.upperwin : this.mainwin, buffer, initiallen ); + this.fix_upper_window(); + }, + + // Request character input + read_char: function( storer, one, time, routine ) + { + // Input stream 1 + if ( this.io.streams[0] ) + { + var code = this.Glk.glk_get_char_stream_uni( this.io.streams[0] ); + // Check for EOF + if ( code === -1 ) + { + this.input_stream( 0 ); + } + else + { + this.variable( storer, code ); + return this.stop = 0; + } + } + + this.read_data = { + routine: routine, + storer: storer, + time: time, + }; + this.Glk.glk_request_char_event_uni( this.io.currentwin ? this.upperwin : this.mainwin ); + this.fix_upper_window(); + }, + + set_colour: function(foreground, background) + { + this.set_true_colour(zcolours[foreground], zcolours[background]) + }, + + // Note that row and col must be decremented in JIT code + set_cursor: function( row, col ) + { + var io = this.io; + + // 8.7.2.3: do nothing if the lower window is selected + if ( !io.currentwin ) + { + return + } + + if ( row >= io.height ) + { + // Moving the cursor to a row forces the upper window + // to open enough for that line to exist + this.split_window( row + 1 ); + } + if ( this.upperwin && row >= 0 && col >= 0 && col < io.width ) + { + this.Glk.glk_window_move_cursor( this.upperwin, col, row ); + io.row = row; + io.col = col; + } + }, + + set_font: function( font ) + { + var returnval = this.io.mono & 0x04 ? 4 : 1; + if ( font === 0 ) + { + return returnval; + } + // We only support fonts 1 and 4 + if ( font !== 1 && font !== 4 ) + { + return 0; + } + if ( font !== returnval ) + { + this.io.mono ^= 0x04; + this.format(); + } + return returnval; + }, + + // Set styles + set_style: function( stylebyte ) + { + var io = this.io; + + // Setting the style to Roman will clear the others + if ( stylebyte === 0 ) + { + io.reverse = io.bold = io.italic = 0; + io.mono &= 0xFE; + } + if ( stylebyte & 0x01 ) + { + io.reverse = 1; + } + if ( stylebyte & 0x02 ) + { + io.bold = 0x04; + } + if ( stylebyte & 0x04 ) + { + io.italic = 0x02; + } + if ( stylebyte & 0x08 ) + { + io.mono |= 0x01; + } + this.format(); + }, + + // Set true colours + set_true_colour: function(foreground, background) + { + const Glk = this.Glk + if (Glk.glk_gestalt(0x1100, 0)) + { + let fg, bg + if (foreground === 0xFFFE) + { + fg = -2 + } + else + { + if (foreground === 0xFFFF) + { + fg = -1 + } + else + { + fg = convert_true_colour(foreground) + } + this.io.fg = fg + } + + if (background === 0xFFFE) + { + bg = -2 + } + else + { + if (background === 0xFFFF) + { + bg = -1 + } + else + { + bg = convert_true_colour(background) + } + this.io.bg = bg + } + + // Set the colours for each open window + Glk.garglk_set_zcolors_stream(this.mainwin.str, fg, bg) + if (this.upperwin) + { + Glk.garglk_set_zcolors_stream(this.upperwin.str, fg, bg) + } + } + }, + + set_window: function( window ) + { + this.io.currentwin = window; + + // Focusing the upper window resets the cursor to the top left; + // it also opens the upper window if it's not open + if ( window ) + { + this.set_cursor( 0, 0 ); + } + + this.Glk.glk_set_window( this.upperwin && window ? this.upperwin : this.mainwin ); + this.format(); + }, + + split_window: function( lines ) + { + var Glk = this.Glk, + io = this.io, + row = io.row, col = io.col, + oldheight = io.height, + str; + io.height = lines; + + // Erase existing lines if we are expanding into existing rows + if ( this.upperwin && lines > oldheight ) + { + str = Glk.glk_window_get_stream( this.upperwin ); + while ( oldheight < lines ) + { + Glk.glk_window_move_cursor( this.upperwin, 0, oldheight++ ); + Glk.glk_put_jstring_stream( str, Array( io.width + 1 ).join( ' ' ) ); + } + Glk.glk_window_move_cursor( this.upperwin, col, row ); + } + + // Don't decrease the height of the window yet, only increase + if ( lines > io.maxheight ) + { + io.maxheight = lines; + + // Set the height of the window + // Create the window if it doesn't exist + if ( !this.upperwin ) + { + if (this.io.bg >= 0) + { + Glk.glk_stylehint_set(4, 0, 8, this.io.bg) + } + this.upperwin = Glk.glk_window_open( this.mainwin, 0x12, io.maxheight, 4, 203 ); + if (this.Glk.glk_gestalt(0x1100, 0)) + { + Glk.garglk_set_zcolors_stream(this.upperwin.str, this.io.fg, this.io.bg) + } + Glk.glk_stylehint_clear(4, 0, 8) + } + else + { + Glk.glk_window_set_arrangement( Glk.glk_window_get_parent( this.upperwin ), 0x12, io.maxheight, null ); + } + io.glkheight = io.maxheight + } + + if ( lines ) + { + // Reset the cursor if it is now outside the window + if ( io.row >= lines ) + { + this.set_cursor( 0, 0 ); + } + // 8.6.1.1.2: In version three the upper window is always cleared + if ( this.version3 ) + { + Glk.glk_window_clear( this.upperwin ); + } + } + }, + + // Update the header after restarting or restoring + update_header: function() + { + var ram = this.ram; + + // Reset the Xorshift seed + this.xorshift_seed = 0; + + // Update the screen size variables - in version 3 does not actually set the header variables + this.update_screen_size() + + // For version 3 we only set Flags 1 + if ( this.version3 ) + { + return ram.setUint8( 0x01, + ( ram.getUint8( 0x01 ) & 0x8F ) // Keep all except bits 4-6 + | ( this.statuswin ? 0x20 : 0x10 ) // If status win is available then set 0x20 for the upper win also being available, otherwise 0x10 for the status win itself + | 0x40 // Variable pitch font is default - Or can we tell from options if the font is fixed pitch? + ); + } + + // Flags 1 + ram.setUint8( 0x01, + (this.Glk.glk_gestalt(0x1100, 0) ? 1 : 0) // Check if colour is supported + | 0x1C // Bold, italic and mono are supported + | 0x00 // Timed input not supported yet + ); + + // Flags 2: Clear bits 3, 5, 7: no character graphics, mouse or sound effects + // This is really a word, but we only care about the lower byte + ram.setUint8( 0x11, ram.getUint8( 0x11 ) & 0x57 ); + + // Font height/width in "units" + if ( this.version > 4 ) + { + ram.setUint16( 0x26, 0x0101 ) + } + + // Colours + //ram.setUint8( 0x2C, isNaN( this.options.bg ) ? 1 : this.options.bg ); + //ram.setUint8( 0x2D, isNaN( this.options.fg ) ? 1 : this.options.fg ); + //this.extension_table( 5, this.options.fg_true ); + //this.extension_table( 6, this.options.bg_true ); + + // Z Machine Spec revision + ram.setUint16( 0x32, 0x0102 ); + + // Clear flags three, we don't support any of that stuff + this.extension_table( 4, 0 ); + }, + + update_screen_size: function() + { + const Glk = this.Glk + const height_box = new Glk.RefBox() + const width_box = new Glk.RefBox() + const tempwin = Glk.glk_window_open( this.mainwin, 0x12, 0, 4, 0 ) + let height = 0 + let width = 0 + + // The main window is proportional, so its width may not be accurate + // If the upper or status window is present, use its width, or else try to make a temp window + // The height is the total of all windows + + Glk.glk_window_get_size( this.mainwin, width_box, height_box ) + height = height_box.get_value() + + if ( this.upperwin ) + { + Glk.glk_window_get_size( this.upperwin, width_box, height_box ) + height += height_box.get_value() + } + if ( this.statuswin ) + { + Glk.glk_window_get_size( this.statuswin, width_box, height_box ) + height += height_box.get_value() + } + if ( tempwin ) + { + Glk.glk_window_get_size( tempwin, width_box, 0 ) + Glk.glk_window_close( tempwin ) + } + + // Use whichever width was available + width = width_box.get_value() + + // Cap the dimensions + // Height is capped to 254 as 255 means infinite, which breaks some games + height = Math.min( height, 254 ) + width = this.io.width = Math.min( width, 255 ) + + // Update the header + if ( this.version > 3 ) + { + this.ram.setUint8( 0x20, height ) + this.ram.setUint8( 0x21, width ) + } + if ( this.version > 4 ) + { + this.ram.setUint16( 0x22, width ) + this.ram.setUint16( 0x24, height ) + } + + // Fix the cursor if it is outside the window + if ( this.io.col >= width ) + { + this.io.col = width - 1 + } + }, + + // Output the version 3 status line + v3_status: function() + { + if ( !this.statuswin ) + { + return; + } + + var Glk = this.Glk, + str = Glk.glk_window_get_stream( this.statuswin ), + memory = this.m, + width = this.io.width, + hours_score = memory.getUint16( this.globals + 2 ), + mins_turns = memory.getUint16( this.globals + 4 ), + proptable = memory.getUint16( this.objects + 9 * memory.getUint16( this.globals ) + 7 ), + shortname = '' + this.decode( proptable + 1, memory.getUint8( proptable ) * 2 ), + rhs; + + // Handle the turns/score or time + if ( memory.getUint8( 0x01 ) & 0x02 ) + { + rhs = 'Time: ' + ( hours_score % 12 === 0 ? 12 : hours_score % 12 ) + ':' + ( mins_turns < 10 ? '0' : '' ) + mins_turns + ' ' + ( hours_score > 11 ? 'PM' : 'AM' ); + } + else + { + rhs = 'Score: ' + hours_score + ' Turns: ' + mins_turns; + } + + // Print a blank line in reverse + Glk.glk_window_move_cursor( this.statuswin, 0, 0 ); + Glk.glk_put_jstring_stream( str, Array( width + 1 ).join( ' ' ) ); + + // Trim the shortname if necessary + Glk.glk_window_move_cursor( this.statuswin, 0, 0 ); + Glk.glk_put_jstring_stream( str, ' ' + shortname.slice( 0, width - rhs.length - 4 ) ); + + // Print the right hand side + Glk.glk_window_move_cursor( this.statuswin, width - rhs.length - 1, 0 ); + Glk.glk_put_jstring_stream( str, rhs ); + }, + +}; + +},{"../common/utils.js":3}],7:[function(require,module,exports){ +/* + +Z-Machine opcodes +================= + +Copyright (c) 2017 The ifvms.js team +MIT licenced +https://github.com/curiousdannii/ifvms.js + +*/ + +'use strict'; + +/* + +TODO: + Abstract out the signed conversions such that they can be eliminated if possible + don't access memory directly + +*/ + +var AST = require( '../common/ast.js' ), +Variable = AST.Variable, +Opcode = AST.Opcode, +Stopper = AST.Stopper, +Pauser = AST.Pauser, +PauserStorer = AST.PauserStorer, +Brancher = AST.Brancher, +BrancherStorer = AST.BrancherStorer, +Storer = AST.Storer, +Caller = AST.Caller, +CallerStorer = AST.CallerStorer, +opcode_builder = AST.opcode_builder, + +// Common functions, variables and opcodes +simple_func = function( a ) { return '' + a; }, +stack_var = new Variable( this.e, 0 ), +alwaysbranch = opcode_builder( Brancher, function() { return 1; } ), +not = opcode_builder( Storer, function( a ) { return 'e.S2U(~' + a + ')'; } ), + +// Indirect storer opcodes - rather non-generic I'm afraid +// Not used for inc/dec +// @load (variable) -> (result) +// @pull (variable) +// @store (variable) value +Indirect = Storer.subClass({ + storer: 0, + + post: function() + { + var operands = this.operands, + op0 = operands[0], + op0isVar = op0 instanceof Variable; + + // Replace the indirect operand with a Variable, and set .indirect if needed + operands[0] = new Variable( this.e, op0isVar ? op0 : op0.v ); + if ( op0isVar || op0.v === 0 ) + { + operands[0].indirect = 1; + } + + // Get the storer + this.storer = this.code === 142 ? operands.pop() : operands.shift(); + + // @pull needs an added stack. If for some reason it was compiled with two operands this will break! + if ( operands.length === 0 ) + { + operands.push( stack_var ); + } + }, + + func: simple_func, +}), + +Incdec = Opcode.subClass({ + func: function( variable ) + { + var varnum = variable.v - 1, + operator = this.code % 2 ? 1 : -1; + + // Fallback to the runtime function if our variable is a variable operand itself + // Or, if it's a global + if ( variable instanceof Variable || varnum > 14 ) + { + return 'e.incdec(' + variable + ',' + operator + ')'; + } + + return ( varnum < 0 ? 'e.s[e.sp-1]' : 'e.l[' + varnum + ']' ) + ( operator === 1 ? '++' : '--' ); + }, +}), + +// Version 3 @save/restore branch instead of store +V3SaveRestore = Stopper.subClass({ + brancher: 1, + + toString: function() + { + return 'e.stop=1;e.' + ( this.code === 181 ? 'save' : 'restore' ) + '(' + ( this.pc + 1 ) + ')'; + }, +}), + +V45Restore = opcode_builder( PauserStorer, function() { return 'e.restore(' + ( this.next - 1 ) + ')'; } ), +V45Save = opcode_builder( PauserStorer, function() { return 'e.save(' + ( this.next - 1 ) + ')'; } ); + +/*eslint brace-style: "off" */ +/*eslint indent: "off" */ + +module.exports = function( version ) +{ + +return { + +/* je */ 1: opcode_builder( Brancher, function() { return arguments.length === 2 ? this.args( '===' ) : 'e.jeq(' + this.args() + ')'; } ), +/* jl */ 2: opcode_builder( Brancher, function( a, b ) { return a.U2S() + '<' + b.U2S(); } ), +/* jg */ 3: opcode_builder( Brancher, function( a, b ) { return a.U2S() + '>' + b.U2S(); } ), +// Too many U2S/S2U for these... +/* dec_chk */ 4: opcode_builder( Brancher, function( variable, value ) { return 'e.U2S(e.incdec(' + variable + ',-1))<' + value.U2S(); } ), +/* inc_chk */ 5: opcode_builder( Brancher, function( variable, value ) { return 'e.U2S(e.incdec(' + variable + ',1))>' + value.U2S(); } ), +/* jin */ 6: opcode_builder( Brancher, function() { return 'e.jin(' + this.args() + ')'; } ), +/* test */ 7: opcode_builder( Brancher, function() { return 'e.test(' + this.args() + ')'; } ), +/* or */ 8: opcode_builder( Storer, function() { return this.args( '|' ); } ), +/* and */ 9: opcode_builder( Storer, function() { return this.args( '&' ); } ), +/* test_attr */ 10: opcode_builder( Brancher, function() { return 'e.test_attr(' + this.args() + ')'; } ), +/* set_attr */ 11: opcode_builder( Opcode, function() { return 'e.set_attr(' + this.args() + ')'; } ), +/* clear_attr */ 12: opcode_builder( Opcode, function() { return 'e.clear_attr(' + this.args() + ')'; } ), +/* store */ 13: Indirect, +/* insert_obj */ 14: opcode_builder( Opcode, function() { return 'e.insert_obj(' + this.args() + ')'; } ), +/* loadw */ 15: opcode_builder( Storer, function( array, index ) { return 'e.m.getUint16(e.S2U(' + array + '+2*' + index.U2S() + '))'; } ), +/* loadb */ 16: opcode_builder( Storer, function( array, index ) { return 'e.m.getUint8(e.S2U(' + array + '+' + index.U2S() + '))'; } ), +/* get_prop */ 17: opcode_builder( Storer, function() { return 'e.get_prop(' + this.args() + ')'; } ), +/* get_prop_addr */ 18: opcode_builder( Storer, function() { return 'e.find_prop(' + this.args() + ')'; } ), +/* get_next_prop */ 19: opcode_builder( Storer, function() { return 'e.find_prop(' + this.args( ',0,' ) + ')'; } ), +/* add */ 20: opcode_builder( Storer, function() { return 'e.S2U(' + this.args( '+' ) + ')'; } ), +/* sub */ 21: opcode_builder( Storer, function() { return 'e.S2U(' + this.args( '-' ) + ')'; } ), +/* mul */ 22: opcode_builder( Storer, function() { return 'e.S2U(' + this.args( '*' ) + ')'; } ), +/* div */ 23: opcode_builder( Storer, function( a, b ) { return 'e.S2U(parseInt(' + a.U2S() + '/' + b.U2S() + '))'; } ), +/* mod */ 24: opcode_builder( Storer, function( a, b ) { return 'e.S2U(' + a.U2S() + '%' + b.U2S() + ')'; } ), +/* call_2s */ 25: CallerStorer, +/* call_2n */ 26: Caller, +/* set_colour */ 27: opcode_builder( Opcode, function() { return 'e.set_colour(' + this.args() + ')'; } ), +/* throw */ 28: opcode_builder( Stopper, function( value, cookie ) { return 'while(e.frames.length+1>' + cookie + '){e.frameptr=e.frames.pop()}return ' + value; } ), +/* jz */ 128: opcode_builder( Brancher, function( a ) { return a + '===0'; } ), +/* get_sibling */ 129: opcode_builder( BrancherStorer, function( obj ) { return 'e.get_sibling(' + obj + ')'; } ), +/* get_child */ 130: opcode_builder( BrancherStorer, function( obj ) { return 'e.get_child(' + obj + ')'; } ), +/* get_parent */ 131: opcode_builder( Storer, function( obj ) { return 'e.get_parent(' + obj + ')'; } ), +/* get_prop_length */ 132: opcode_builder( Storer, function( a ) { return 'e.get_prop_len(' + a + ')'; } ), +/* inc */ 133: Incdec, +/* dec */ 134: Incdec, +/* print_addr */ 135: opcode_builder( Opcode, function( addr ) { return 'e.print(2,' + addr + ')'; } ), +/* call_1s */ 136: CallerStorer, +/* remove_obj */ 137: opcode_builder( Opcode, function( obj ) { return 'e.remove_obj(' + obj + ')'; } ), +/* print_obj */ 138: opcode_builder( Opcode, function( obj ) { return 'e.print(3,' + obj + ')'; } ), +/* ret */ 139: opcode_builder( Stopper, function( a ) { return 'return ' + a; } ), +/* jump */ 140: opcode_builder( Stopper, function( a ) { return 'e.pc=' + a.U2S() + '+' + ( this.next - 2 ); } ), +/* print_paddr */ 141: opcode_builder( Opcode, function( addr ) { return 'e.print(2,' + addr + '*' + this.e.addr_multipler + ')'; } ), +/* load */ 142: Indirect.subClass( { storer: 1 } ), +143: version < 5 ? + /* not (v3/4) */ not : + /* call_1n (v5/8) */ Caller, +/* rtrue */ 176: opcode_builder( Stopper, function() { return 'return 1'; } ), +/* rfalse */ 177: opcode_builder( Stopper, function() { return 'return 0'; } ), +// Reconsider a generalised class for @print/@print_ret? +/* print */ 178: opcode_builder( Opcode, function( text ) { return 'e.print(2,' + text + ')'; }, { printer: 1 } ), +/* print_ret */ 179: opcode_builder( Stopper, function( text ) { return 'e.print(2,' + text + ');e.print(1,13);return 1'; }, { printer: 1 } ), +/* nop */ 180: Opcode, +/* save (v3/4) */ 181: version < 4 ? + V3SaveRestore : + V45Save, +/* restore(v3/4) */ 182: version < 4 ? + V3SaveRestore : + V45Restore, +/* restart */ 183: opcode_builder( Stopper, function() { return 'e.restart()'; } ), +/* ret_popped */ 184: opcode_builder( Stopper, function( a ) { return 'return ' + a; }, { post: function() { this.operands.push( stack_var ); } } ), +185: version < 5 ? + /* pop (v3/4) */ opcode_builder( Opcode, function() { return 's[--e.sp]'; } ) : + /* catch (v5/8) */ opcode_builder( Storer, function() { return 'e.frames.length+1'; } ), +/* quit */ 186: opcode_builder( Pauser, function() { return 'e.quit=1;e.Glk.glk_exit()'; } ), +/* new_line */ 187: opcode_builder( Opcode, function() { return 'e.print(1,13)'; } ), +188: version < 4 ? + /* show_status (v3) */ opcode_builder( Stopper, function() { return 'e.pc=' + this.next + ';e.v3_status()'; } ) : + /* act as a nop in later versions */ Opcode, +/* verify */ 189: alwaysbranch, // Actually check?? +/* piracy */ 191: alwaysbranch, +/* call_vs */ 224: CallerStorer, +/* storew */ 225: opcode_builder( Opcode, function( array, index, value ) { return 'e.ram.setUint16(e.S2U(' + array + '+2*' + index.U2S() + '),' + value + ')'; } ), +/* storeb */ 226: opcode_builder( Opcode, function( array, index, value ) { return 'e.ram.setUint8(e.S2U(' + array + '+' + index.U2S() + '),' + value + ')'; } ), +/* put_prop */ 227: opcode_builder( Opcode, function() { return 'e.put_prop(' + this.args() + ')'; } ), +/* read */ 228: version < 5 ? + opcode_builder( Pauser, function() { return 'e.read(0,' + this.args() + ')'; } ) : + opcode_builder( PauserStorer, function() { return 'e.read(' + this.storer.v + ',' + this.args() + ')'; } ), +/* print_char */ 229: opcode_builder( Opcode, function( a ) { return 'e.print(4,' + a + ')'; } ), +/* print_num */ 230: opcode_builder( Opcode, function( a ) { return 'e.print(0,' + a.U2S() + ')'; } ), +/* random */ 231: opcode_builder( Storer, function( a ) { return 'e.random(' + a.U2S() + ')'; } ), +/* push */ 232: opcode_builder( Storer, simple_func, { post: function() { this.storer = stack_var; }, storer: 0 } ), +/* pull */ 233: Indirect, +/* split_window */ 234: opcode_builder( Opcode, function( lines ) { return 'e.split_window(' + lines + ')'; } ), +/* set_window */ 235: opcode_builder( Opcode, function( wind ) { return 'e.set_window(' + wind + ')'; } ), +/* call_vs2 */ 236: CallerStorer, +/* erase_window */ 237: opcode_builder( Opcode, function( win ) { return 'e.erase_window(' + win.U2S() + ')'; } ), +/* erase_line */ 238: opcode_builder( Opcode, function( a ) { return 'e.erase_line(' + a + ')'; } ), +/* set_cursor */ 239: opcode_builder( Opcode, function( row, col ) { return 'e.set_cursor(' + row + '-1,' + col + '-1)'; } ), +/* get_cursor */ 240: opcode_builder( Opcode, function( addr ) { return 'e.get_cursor(' + addr + ')'; } ), +/* set_text_style */ 241: opcode_builder( Opcode, function( stylebyte ) { return 'e.set_style(' + stylebyte + ')'; } ), +/* buffer_mode */ 242: Opcode, // We don't support non-buffered output +/* output_stream */ 243: opcode_builder( Stopper, function() { return 'e.pc=' + this.next + ';e.output_stream(' + this.args() + ')'; } ), +/* input_stream */ 244: opcode_builder( Pauser, function() { return 'e.input_stream(' + this.args() + ')'; } ), +/* sound_effect */ 245: Opcode, // We don't support sounds +/* read_char */ 246: opcode_builder( PauserStorer, function() { return 'e.read_char(' + this.storer.v + ',' + ( this.args() || '1' ) + ')'; } ), +/* scan_table */ 247: opcode_builder( BrancherStorer, function() { return 'e.scan_table(' + this.args() + ')'; } ), +/* not (v5/8) */ 248: not, +/* call_vn */ 249: Caller, +/* call_vn2 */ 250: Caller, +/* tokenise */ 251: opcode_builder( Opcode, function() { return 'e.tokenise(' + this.args() + ')'; } ), +/* encode_text */ 252: opcode_builder( Opcode, function() { return 'e.encode_text(' + this.args() + ')'; } ), +/* copy_table */ 253: opcode_builder( Opcode, function() { return 'e.copy_table(' + this.args() + ')'; } ), +/* print_table */ 254: opcode_builder( Opcode, function() { return 'e.print_table(' + this.args() + ')'; } ), +/* check_arg_count */ 255: opcode_builder( Brancher, function( arg ) { return 'e.stack.getUint8(e.frameptr+5)&(1<<(' + arg + '-1))'; } ), +/* save */ 1000: V45Save, +/* restore */ 1001: V45Restore, +/* log_shift */ 1002: opcode_builder( Storer, function( a, b ) { return 'e.S2U(e.log_shift(' + a + ',' + b.U2S() + '))'; } ), +/* art_shift */ 1003: opcode_builder( Storer, function( a, b ) { return 'e.S2U(e.art_shift(' + a.U2S() + ',' + b.U2S() + '))'; } ), +/* set_font */ 1004: opcode_builder( Storer, function( font ) { return 'e.set_font(' + font + ')'; } ), +/* save_undo */ 1009: opcode_builder( Storer, function() { return 'e.save_undo(' + this.next + ',' + this.storer.v + ')'; } ), +// As the standard says calling this without a save point is illegal, we don't need to actually store anything (but it must still be disassembled) +/* restore_undo */ 1010: opcode_builder( Opcode, function() { return 'if(e.restore_undo())return'; }, { storer: 1 } ), +/* print_unicode */ 1011: opcode_builder( Opcode, function( a ) { return 'e.print(1,' + a + ')'; } ), +// Assume we can print and read all unicode characters rather than actually testing +/* check_unicode */ 1012: opcode_builder( Storer, function() { return 3; } ), +/* set_true_colour */ 1013: opcode_builder( Opcode, function() { return 'e.set_true_colour(' + this.args() + ')'; } ), +/* sound_data */ 1014: Opcode.subClass( { brancher: 1 } ), // We don't support sounds (but disassemble the branch address) +/* gestalt */ 1030: opcode_builder( Storer, function() { return 'e.gestalt(' + this.args() + ')'; } ), +/* parchment */ //1031: opcode_builder( Storer, function() { return 'e.op_parchment(' + this.args() + ')'; } ), + +}; + +}; + +},{"../common/ast.js":1}],8:[function(require,module,exports){ +/* + +Z-Machine runtime functions +=========================== + +Copyright (c) 2020 The ifvms.js team +MIT licenced +https://github.com/curiousdannii/ifvms.js + +*/ + +'use strict'; + +/* + +TODO: + Save/restore: table, name, prompt support + +*/ + +const file = require( '../common/file.js' ) +const utils = require( '../common/utils.js' ) +const extend = utils.extend +const U2S = utils.U2S16 +const S2U = utils.S2U16 + +// A clone function which ignores the properties we don't want to serialise +function clone( obj ) +{ + const recurse = obj => typeof obj === 'object' ? clone( obj ) : obj + const newobj = {} + + if ( Array.isArray( obj ) ) + { + return obj.map( recurse ) + } + + for ( let prop in obj ) + { + if ( prop !== 'buffer' && prop !== 'str' ) + { + newobj[prop] = recurse( obj[prop] ) + } + } + return newobj +} + +// Test whether we are running on a littleEndian system +const littleEndian = (function() +{ + var testUint8Array = new Uint8Array( 2 ), + testUint16Array = new Uint16Array( testUint8Array.buffer ); + testUint16Array[0] = 1; + return testUint8Array[0] === 1; +})() + +function fix_stack_endianness( view, start, end, auto ) +{ + if ( littleEndian && !auto ) + { + while ( start < end ) + { + view.setUint16( start, view.getUint16( start, 1 ) ); + start += 2; + } + } +} + +module.exports = { + + art_shift: function( number, places ) + { + return places > 0 ? number << places : number >> -places; + }, + + // Call a routine + call: function( addr, storer, next, args ) + { + // 6.4.3: Calls to 0 instead just store 0 + if ( addr === 0 ) + { + if ( storer >= 0 ) + { + this.variable( storer, 0 ); + } + return this.pc = next; + } + + // Get the number of locals and advance the pc + this.pc = addr * this.addr_multipler; + var locals_count = this.m.getUint8( this.pc++ ), + + stack = this.stack, + i = 0, + + // Write the current stack use + frameptr = this.frameptr; + stack.setUint16( frameptr + 6, this.sp ); + this.frames.push( frameptr ); + + // Create a new frame + frameptr = this.frameptr = this.s.byteOffset + this.sp * 2; + // Return address + stack.setUint32( frameptr, next << 8 ); + // Flags + stack.setUint8( frameptr + 3, ( storer >= 0 ? 0 : 0x10 ) | locals_count ); + // Storer + stack.setUint8( frameptr + 4, storer >= 0 ? storer : 0 ); + // Supplied arguments + stack.setUint8( frameptr + 5, ( 1 << args.length ) - 1 ); + + // Create the locals and stack + this.make_stacks(); + this.sp = 0; + while ( i < locals_count ) + { + this.l[i] = i < args.length ? args[i] : ( this.version < 5 ? this.m.getUint16( this.pc + i * 2 ) : 0 ); + i++; + } + if ( this.version < 5 ) + { + this.pc += locals_count * 2; + } + }, + + clear_attr: function( object, attribute ) + { + var addr = this.objects + ( this.version3 ? 9 : 14 ) * object + ( attribute / 8 ) | 0; + this.ram.setUint8( addr, this.m.getUint8( addr ) & ~( 0x80 >> attribute % 8 ) ); + }, + + copy_table: function( first, second, size ) + { + size = U2S( size ); + var ram = this.ram, + i = 0, + allowcorrupt = size < 0; + size = Math.abs( size ); + + // Simple case, zeroes + if ( second === 0 ) + { + while ( i < size ) + { + ram.setUint8( first + i++, 0 ); + } + return; + } + + if ( allowcorrupt ) + { + while ( i < size ) + { + ram.setUint8( second + i, this.m.getUint8( first + i++ ) ); + } + } + else + { + ram.setUint8Array( second, this.m.getUint8Array( first, size ) ); + } + }, + + do_autorestore: function( snapshot ) + { + const Glk = this.Glk + + // Restore Glk + Glk.restore_allstate( snapshot.glk ) + + // Get references to our Glk objects + this.io = snapshot.io + const RockBox = new Glk.RefBox() + let obj + while ( obj = Glk.glk_window_iterate( obj, RockBox ) ) + { + if ( RockBox.value === 201 ) + { + this.mainwin = obj + if ( obj.linebuf ) + { + snapshot.read_data.buffer = obj.linebuf + } + } + if ( RockBox.value === 202 ) + { + this.statuswin = obj + } + if ( RockBox.value === 203 ) + { + this.upperwin = obj + } + } + obj = null + while ( obj = Glk.glk_stream_iterate( obj, RockBox ) ) + { + if ( RockBox.value === 210 ) + { + this.io.streams[2].str = obj + } + if ( RockBox.value === 211 ) + { + this.io.streams[4].str = obj + } + } + + // Restart and restore the RAM and stacks + this.restart(1) + this.restore_file(this.options.Dialog.streaming ? new Uint8Array(snapshot.ram) : Uint8Array.from(snapshot.ram), 1) + + // Set remaining data from the snapshot + this.read_data = snapshot.read_data + this.xorshift_seed = snapshot.xorshift_seed + }, + + do_autosave: function( save ) + { + if ( !this.options.Dialog ) + { + throw new Error( 'A reference to Dialog is required' ) + } + + let snapshot = null + if ( ( save || 0 ) >= 0 ) + { + const ram = this.save_file(this.pc, 1) + snapshot = { + glk: this.Glk.save_allstate(), + io: clone( this.io ), + ram: this.options.Dialog.streaming ? ram : Array.from(new Uint8Array(ram)), + read_data: clone( this.read_data ), + xorshift_seed: this.xorshift_seed, + } + } + + this.options.Dialog.autosave_write( this.signature, snapshot ) + }, + + encode_text: function( zscii, length, from, target ) + { + this.ram.setUint8Array( target, this.encode( this.m.getUint8Array( zscii + from, length ) ) ); + }, + + // Access the extension table + extension_table: function( word, value ) + { + var addr = this.extension; + if ( !addr || word > this.extension_count ) + { + return 0; + } + addr += 2 * word; + if ( value === undefined ) + { + return this.m.getUint16( addr ); + } + this.ram.setUint16( addr, value ); + }, + + // Find the address of a property, or given the previous property, the number of the next + find_prop: function( object, property, prev ) + { + var memory = this.m, + version3 = this.version3, + + this_property_byte, this_property, + last_property = 0, + + // Get this property table + properties = memory.getUint16( this.objects + ( version3 ? 9 : 14 ) * object + ( version3 ? 7 : 12 ) ); + + // Skip over the object's short name + properties += memory.getUint8( properties ) * 2 + 1; + + // Run through the properties + while ( 1 ) + { + this_property_byte = memory.getUint8( properties ); + this_property = this_property_byte & ( version3 ? 0x1F : 0x3F ); + + // Found the previous property, so return this one's number + if ( last_property === prev ) + { + return this_property; + } + // Found the property! Return its address + if ( this_property === property ) + { + // Must include the offset + return properties + ( !version3 && this_property_byte & 0x80 ? 2 : 1 ); + } + // Gone past the property + if ( this_property < property ) + { + return 0; + } + + // Go to next property + last_property = this_property; + + // Calculate the size of this property and skip to the next + if ( version3 ) + { + properties += ( this_property_byte >> 5 ) + 2; + } + else + { + if ( this_property_byte & 0x80 ) + { + this_property = memory.getUint8( properties + 1 ) & 0x3F; + properties += this_property ? this_property + 2 : 66; + } + else + { + properties += this_property_byte & 0x40 ? 3 : 2; + } + } + } + }, + + // 1.2 spec @gestalt + gestalt: function( id /*, arg*/ ) + { + switch ( id ) + { + case 1: + return 0x0102; + } + return 0; + }, + + // Get the first child of an object + get_child: function( obj ) + { + if ( this.version3 ) + { + return this.m.getUint8( this.objects + 9 * obj + 6 ); + } + else + { + return this.m.getUint16( this.objects + 14 * obj + 10 ); + } + }, + + get_sibling: function( obj ) + { + if ( this.version3 ) + { + return this.m.getUint8( this.objects + 9 * obj + 5 ); + } + else + { + return this.m.getUint16( this.objects + 14 * obj + 8 ); + } + }, + + get_parent: function( obj ) + { + if ( this.version3 ) + { + return this.m.getUint8( this.objects + 9 * obj + 4 ); + } + else + { + return this.m.getUint16( this.objects + 14 * obj + 6 ); + } + }, + + get_prop: function( object, property ) + { + var memory = this.m, + + // Try to find the property + addr = this.find_prop( object, property ), + len; + + // If we have the property + if ( addr ) + { + len = memory.getUint8( addr - 1 ); + // Assume we're being called for a valid short property + return memory[ ( this.version3 ? len >> 5 : len & 0x40 ) ? 'getUint16' : 'getUint8' ]( addr ); + } + + // Use the default properties table + // Remember that properties are 1-indexed + return memory.getUint16( this.properties + 2 * ( property - 1 ) ); + }, + + // Get the length of a property + // This opcode expects the address of the property data, not a property block + get_prop_len: function( addr ) + { + // Spec 1.1 + if ( addr === 0 ) + { + return 0; + } + + var value = this.m.getUint8( addr - 1 ); + + // Version 3 + if ( this.version3 ) + { + return ( value >> 5 ) + 1; + } + + // Two size/number bytes + if ( value & 0x80 ) + { + value &= 0x3F; + return value === 0 ? 64 : value; + } + // One byte size/number + return value & 0x40 ? 2 : 1; + }, + + // Quick hack for @inc/@dec/@inc_chk/@dec_chk + incdec: function( varnum, change ) + { + if ( varnum === 0 ) + { + this.s[this.sp - 1] += change; + return this.s[this.sp - 1]; + } + if ( --varnum < 15 ) + { + this.l[varnum] += change; + return this.l[varnum]; + } + else + { + var offset = this.globals + ( varnum - 15 ) * 2; + this.ram.setUint16( offset, this.m.getUint16( offset ) + change ); + return this.ram.getUint16( offset ); + } + }, + + // Indirect variables + indirect: function( variable, value ) + { + if ( variable === 0 ) + { + if ( arguments.length > 1 ) + { + return this.s[this.sp - 1] = value; + } + else + { + return this.s[this.sp - 1]; + } + } + return this.variable( variable, value ); + }, + + insert_obj: function( obj, dest ) + { + // First remove the obj from wherever it was + this.remove_obj( obj ); + // Now add it to the destination + this.set_family( obj, dest, dest, obj, obj, this.get_child( dest ) ); + }, + + // @jeq + jeq: function() + { + var i = 1; + + // Account for many arguments + while ( i < arguments.length ) + { + if ( arguments[i++] === arguments[0] ) + { + return 1; + } + } + }, + + jin: function( child, parent ) + { + return this.get_parent( child ) === parent; + }, + + log: function( message ) + { + if ( this.options.GlkOte ) + { + this.options.GlkOte.log( message ); + } + }, + + log_shift: function( number, places ) + { + return places > 0 ? number << places : number >>> -places; + }, + + make_stacks: function() + { + var locals_count = this.stack.getUint8( this.frameptr + 3 ) & 0x0F; + this.l = new Uint16Array( this.stack.buffer, this.frameptr + 8, locals_count ); + this.s = new Uint16Array( this.stack.buffer, this.frameptr + 8 + locals_count * 2 ); + }, + + put_prop: function( object, property, value ) + { + // Try to find the property + var addr = this.find_prop( object, property ), + len; + + if ( addr ) + { + len = this.m.getUint8( addr - 1 ); + + // Assume we're being called for a valid short property + this.ram[ ( this.version3 ? len >> 5 : len & 0x40 ) ? 'setUint16' : 'setUint8' ]( addr, value ); + } + }, + + random: function( range ) + { + var seed = this.xorshift_seed; + + // Switch to the Xorshift RNG (or switch off if range == 0) + if ( range < 1 ) + { + this.xorshift_seed = range; + return 0; + } + + // Pure randomness + if ( seed === 0 ) + { + return 1 + ( Math.random() * range ) | 0; + } + + // Based on the discussions in this forum topic, we will not implement the sequential mode recommended in the standard + // http://www.intfiction.org/forum/viewtopic.php?f=38&t=16023 + + // Instead implement a 32 bit Xorshift generator + seed ^= ( seed << 13 ); + seed ^= ( seed >> 17 ); + this.xorshift_seed = ( seed ^= ( seed << 5 ) ); + return 1 + ( ( seed & 0x7FFF ) % range ); + }, + + remove_obj: function( obj ) + { + var parent = this.get_parent( obj ), + older_sibling, + younger_sibling, + temp_younger; + + // No parent, do nothing + if ( parent === 0 ) + { + return; + } + + older_sibling = this.get_child( parent ); + younger_sibling = this.get_sibling( obj ); + + // obj is first child + if ( older_sibling === obj ) + { + this.set_family( obj, 0, parent, younger_sibling ); + } + // obj isn't first child, so fix the older sibling + else + { + // Go through the tree until we find the older sibling + while ( 1 ) + { + temp_younger = this.get_sibling( older_sibling ); + if ( temp_younger === obj ) + { + break; + } + older_sibling = temp_younger; + } + this.set_family( obj, 0, 0, 0, older_sibling, younger_sibling ); + } + }, + + // (Re)start the VM + restart: function(autorestoring) + { + var ram = this.ram, + version = ram.getUint8( 0x00 ), + version3 = version === 3, + addr_multipler = version3 ? 2 : ( version === 8 ? 8 : 4 ), + flags2 = ram.getUint8( 0x11 ), + property_defaults = ram.getUint16( 0x0A ), + extension = ( version > 4 ) ? ram.getUint16( 0x36 ) : 0, + stack = utils.MemoryView( this.options.stack_len ); + + // Reset the RAM, but preserve flags 2 + ram.setUint8Array( 0, this.origram ); + ram.setUint8( 0x11, flags2 ); + + extend( this, { + + // Locals and stacks of various kinds + stack: stack, + frameptr: 0, + frames: [], + s: new Uint16Array( stack.buffer, 8 ), + sp: 0, + l: [], + undo: [], + undo_len: 0, + + glk_blocking_call: null, + + // Get some header variables + version: version, + version3: version3, + pc: ram.getUint16( 0x06 ), + properties: property_defaults, + objects: property_defaults + ( version3 ? 53 : 112 ), // 62-9 or 126-14 - if we take this now then we won't need to always decrement the object number + globals: ram.getUint16( 0x0C ), + // staticmem: set in prepare() + eof: ( ram.getUint16( 0x1A ) || 65536 ) * addr_multipler, + extension: extension, + extension_count: extension ? ram.getUint16( extension ) : 0, + + // Routine and string multiplier + addr_multipler: addr_multipler, + + // Opcodes for this version of the Z-Machine + opcodes: require( './opcodes.js' )( version ), + + }); + + this.init_text(); + if (!autorestoring) + { + this.init_io() + } + + // Update the header + this.update_header(); + }, + + // Request a restore + restore: function( pc ) + { + this.pc = pc; + this.fileref_create_by_prompt({ + func: 'restore', + mode: 0x02, + usage: 0x01, + }); + }, + + restore_file: function( data, autorestoring ) + { + var ram = this.ram, + quetzal = new file.Quetzal( data ), + qmem = quetzal.memory, + stack = this.stack, + flags2 = ram.getUint8( 0x11 ), + temp, + i = 0, j = 0; + + // Check this is a savefile for this story + if ( ram.getUint16( 0x02 ) !== quetzal.release || ram.getUint16( 0x1C ) !== quetzal.checksum ) + { + return 0; + } + while ( i < 6 ) + { + if ( ram.getUint8( 0x12 + i ) !== quetzal.serial[i++] ) + { + return 0; + } + } + i = 0; + + // Memory chunk + // Reset the RAM + ram.setUint8Array( 0, this.origram ); + if ( quetzal.compressed ) + { + while ( i < qmem.length ) + { + temp = qmem[i++]; + // Same memory + if ( temp === 0 ) + { + j += 1 + qmem[i++]; + } + else + { + ram.setUint8( j, temp ^ this.origram[j++] ); + } + } + } + else + { + ram.setUint8Array( 0, qmem ); + } + // Preserve flags 2 + ram.setUint8( 0x11, flags2 ); + + // Stacks + stack.setUint8Array( 0, quetzal.stacks ); + this.frames = []; + i = 0; + while ( i < quetzal.stacks.byteLength ) + { + this.frameptr = i; + this.frames.push( i ); + // Swap the bytes of the locals and stacks + fix_stack_endianness( stack, j = i + 8, j += ( stack.getUint8( i + 3 ) & 0x0F ) * 2, autorestoring ) + fix_stack_endianness( stack, j, j += stack.getUint16( i + 6 ) * 2, autorestoring ) + i = j; + } + this.frames.pop(); + this.sp = stack.getUint16( this.frameptr + 6 ); + this.make_stacks(); + + this.pc = quetzal.pc; + this.update_header(); + + // Collapse the upper window (8.6.1.3) + if ( this.version3 ) + { + this.split_window( 0 ); + } + + return 2; + }, + + restore_undo: function() + { + if ( this.undo.length === 0 ) + { + return 0; + } + + var state = this.undo.pop(); + this.frameptr = state.frameptr; + this.pc = state.pc; + this.undo_len -= ( state.ram.byteLength + state.stack.byteLength ); + + // Replace the ram, preserving flags 2 + state.ram[0x11] = this.m.getUint8( 0x11 ); + this.ram.setUint8Array( 0, state.ram ); + + // Fix up the stack + this.frames = state.frames; + this.sp = state.sp; + this.stack.setUint8Array( 0, state.stack ); + this.make_stacks(); + + this.variable( state.var, 2 ); + return 1; + }, + + // Return from a routine + ret: function( result ) + { + var stack = this.stack, + + // Get the storer and return pc from this frame + frameptr = this.frameptr, + storer = stack.getUint8( frameptr + 3 ) & 0x10 ? -1 : stack.getUint8( frameptr + 4 ); + this.pc = stack.getUint32( frameptr ) >> 8; + + // Recreate the locals and stacks from the previous frame + frameptr = this.frameptr = this.frames.pop(); + this.make_stacks(); + this.sp = stack.getUint16( frameptr + 6 ); + + // Store the result if there is one + if ( storer >= 0 ) + { + this.variable( storer, result || 0 ); + } + }, + + // pc is the address of the storer operand (or branch in v3) + save: function( pc ) + { + this.pc = pc; + this.fileref_create_by_prompt({ + func: 'save', + mode: 0x01, + usage: 0x01, + }); + }, + + save_file: function( pc, autosaving ) + { + var memory = this.m, + quetzal = new file.Quetzal(), + stack = utils.MemoryView( this.stack.buffer.slice() ), + zeroes = 0, + i, j, + frameptr = this.frameptr, + abyte; + + // IFhd chunk + quetzal.release = memory.getUint16( 0x02 ); + quetzal.serial = memory.getUint8Array( 0x12, 6 ); + quetzal.checksum = memory.getUint16( 0x1C ); + quetzal.pc = pc; + + // Memory chunk + if ( autosaving ) + { + quetzal.memory = this.m.getUint8Array( 0, this.staticmem ) + } + else + { + const compressed_mem = [] + quetzal.compressed = 1; + for ( i = 0; i < this.staticmem; i++ ) + { + abyte = memory.getUint8( i ) ^ this.origram[i]; + if ( abyte === 0 ) + { + if ( ++zeroes === 256 ) + { + compressed_mem.push( 0, 255 ); + zeroes = 0; + } + } + else + { + if ( zeroes ) + { + compressed_mem.push( 0, zeroes - 1 ); + zeroes = 0; + } + compressed_mem.push( abyte ); + } + } + quetzal.memory = compressed_mem; + } + + // Stacks + // Set the current sp + stack.setUint16( frameptr + 6, this.sp ); + + // Swap the bytes of the locals and stacks + if ( littleEndian && !autosaving ) + { + const frames = this.frames.slice() + frames.push( frameptr ) + for ( i = 0; i < frames.length; i++ ) + { + frameptr = frames[i]; + fix_stack_endianness( stack, j = frameptr + 8, j += ( stack.getUint8( frameptr + 3 ) & 0x0F ) * 2 ); + fix_stack_endianness( stack, j, j += stack.getUint16( frameptr + 6 ) * 2 ); + } + } + quetzal.stacks = stack.getUint8Array( 0, this.frameptr + 8 + ( stack.getUint8( frameptr + 3 ) & 0x0F ) * 2 + this.sp * 2 ); + + return quetzal.write(); + }, + + save_restore_handler: function( str ) + { + var memory = this.m, + Glk = this.Glk, + result = 0, + buffer = [], + temp, iftrue, offset; + + if ( str ) + { + // Save + if ( this.fileref_data.func === 'save' ) + { + Glk.glk_put_buffer_stream( str, new Uint8Array( this.save_file( this.pc ) ) ); + result = 1; + } + // Restore + else + { + buffer = new Uint8Array( 128 * 1024 ); + Glk.glk_get_buffer_stream( str, buffer ); + result = this.restore_file( buffer.buffer ); + } + Glk.glk_stream_close( str ); + } + + // Store the result / branch in z3 + if ( this.version3 ) + { + // Calculate the branch + temp = memory.getUint8( this.pc++ ); + iftrue = temp & 0x80; + offset = temp & 0x40 ? + // single byte address + temp & 0x3F : + // word address, but first get the second byte of it + ( temp << 8 | memory.getUint8( this.pc++ ) ) << 18 >> 18; + + if ( !result === !iftrue ) + { + if ( offset === 0 || offset === 1 ) + { + this.ret( offset ); + } + else + { + this.pc += offset - 2; + } + } + } + else + { + this.variable( memory.getUint8( this.pc++ ), result ); + } + }, + + save_undo: function( pc, variable ) + { + // Drop an old undo state if we've reached the limit, but always save at least one state + var state + if ( this.undo_len > this.options.undo_len ) + { + state = this.undo.shift() + this.undo_len -= ( state.ram.byteLength + state.stack.byteLength ) + } + state = { + frameptr: this.frameptr, + frames: this.frames.slice(), + pc: pc, + ram: this.m.getUint8Array( 0, this.staticmem ), + sp: this.sp, + stack: this.stack.getUint8Array( 0, this.s.byteOffset + this.sp * 2 ), + var: variable, + } + this.undo_len += ( state.ram.byteLength + state.stack.byteLength ) + this.undo.push( state ) + return 1 + }, + + scan_table: function( key, addr, length, form ) + { + form = form || 0x82; + var memoryfunc = form & 0x80 ? 'getUint16' : 'getUint8'; + form &= 0x7F; + length = addr + length * form; + + while ( addr < length ) + { + if ( this.m[memoryfunc]( addr ) === key ) + { + return addr; + } + addr += form; + } + return 0; + }, + + set_attr: function( object, attribute ) + { + var addr = this.objects + ( this.version3 ? 9 : 14 ) * object + ( attribute / 8 ) | 0; + this.ram.setUint8( addr, this.m.getUint8( addr ) | 0x80 >> attribute % 8 ); + }, + + set_family: function( obj, newparent, parent, child, bigsis, lilsis ) + { + var ram = this.ram, + objects = this.objects; + + if ( this.version3 ) + { + // Set the new parent of the obj + ram.setUint8( objects + 9 * obj + 4, newparent ); + // Update the parent's first child if needed + if ( parent ) + { + ram.setUint8( objects + 9 * parent + 6, child ); + } + // Update the little sister of a big sister + if ( bigsis ) + { + ram.setUint8( objects + 9 * bigsis + 5, lilsis ); + } + } + else + { + // Set the new parent of the obj + ram.setUint16( objects + 14 * obj + 6, newparent ); + // Update the parent's first child if needed + if ( parent ) + { + ram.setUint16( objects + 14 * parent + 10, child ); + } + // Update the little sister of a big sister + if ( bigsis ) + { + ram.setUint16( objects + 14 * bigsis + 8, lilsis ); + } + } + }, + + test: function( bitmap, flag ) + { + return ( bitmap & flag ) === flag; + }, + + test_attr: function( object, attribute ) + { + return ( this.m.getUint8( this.objects + ( this.version3 ? 9 : 14 ) * object + ( attribute / 8 ) | 0 ) << attribute % 8 ) & 0x80; + }, + + // Read or write a variable + variable: function( variable, value ) + { + var havevalue = value !== undefined, + offset; + if ( variable === 0 ) + { + if ( havevalue ) + { + this.s[this.sp++] = value; + } + else + { + return this.s[--this.sp]; + } + } + else if ( --variable < 15 ) + { + if ( havevalue ) + { + this.l[variable] = value; + } + else + { + return this.l[variable]; + } + } + else + { + offset = this.globals + ( variable - 15 ) * 2; + if ( havevalue ) + { + this.ram.setUint16( offset, value ); + } + else + { + return this.m.getUint16( offset ); + } + } + return value; + }, + + // Utilities for signed arithmetic + U2S: U2S, + S2U: S2U, + +}; + +},{"../common/file.js":2,"../common/utils.js":3,"./opcodes.js":7}],9:[function(require,module,exports){ +/* + +Z-Machine text functions +======================== + +Copyright (c) 2017 The ifvms.js team +MIT licenced +https://github.com/curiousdannii/ifvms.js + +*/ + +/* + +TODO: + Consider quote suggestions from 1.1 spec + +*/ + +module.exports = { + + init_text: function() + { + var self = this, + memory = this.m, + + alphabet_addr = ( this.version > 4 ) && memory.getUint16( 0x34 ), + unicode_addr = this.extension_table( 3 ), + unicode_len = unicode_addr && memory.getUint8( unicode_addr++ ); + + this.abbr_addr = memory.getUint16( 0x18 ); + + // Generate alphabets + function make_alphabet( data ) + { + var alphabets = [[], [], []], + i = 0; + while ( i < 78 ) + { + alphabets[( i / 26 ) | 0][i % 26] = data[ i++ ]; + } + // A2->7 is always a newline + alphabets[2][1] = 13; + self.alphabets = alphabets; + } + + // Make the unicode tables + function make_unicode( data ) + { + var table = { 13: '\r' }, // New line conversion + reverse = { 13: 13 }, + i = 0; + while ( i < data.length ) + { + table[155 + i] = String.fromCharCode( data[i] ); + reverse[data[i]] = 155 + i++; + } + i = 32; + while ( i < 127 ) + { + table[i] = String.fromCharCode( i ); + reverse[i] = i++; + } + self.unicode_table = table; + self.reverse_unicode_table = reverse; + } + + // Check for custom alphabets + make_alphabet( alphabet_addr ? memory.getUint8Array( alphabet_addr, 78 ) + // Or use the standard alphabet + : this.text_to_zscii( 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ \r0123456789.,!?_#\'"/\\-:()', 1 ) ); + + // Check for a custom unicode table + make_unicode( unicode_addr ? memory.getUint16Array( unicode_addr, unicode_len ) + // Or use the default + : this.text_to_zscii( unescape( '%E4%F6%FC%C4%D6%DC%DF%BB%AB%EB%EF%FF%CB%CF%E1%E9%ED%F3%FA%FD%C1%C9%CD%D3%DA%DD%E0%E8%EC%F2%F9%C0%C8%CC%D2%D9%E2%EA%EE%F4%FB%C2%CA%CE%D4%DB%E5%C5%F8%D8%E3%F1%F5%C3%D1%D5%E6%C6%E7%C7%FE%F0%DE%D0%A3%u0153%u0152%A1%BF' ), 1 ) ); + + // Parse the standard dictionary + this.dictionaries = {}; + this.dict = memory.getUint16( 0x08 ); + this.parse_dict( this.dict ); + + // Optimise our own functions + /*if ( DEBUG ) + { + if ( !debugflags.nooptimise ) + optimise_obj( this, 'TEXT' ); + }*/ + }, + + // Decode Z-chars into ZSCII and then Unicode + decode: function( addr, length ) + { + var memory = this.m, + + start_addr = addr, + temp, + buffer = [], + i = 0, + zchar, + alphabet = 0, + result = [], + resulttexts = [], + usesabbr, + unicodecount = 0; + + // Check if this one's been cached already + if ( this.jit[addr] ) + { + return this.jit[addr]; + } + + // If we've been given a length, then use it as the finaladdr, + // Otherwise don't go past the end of the file + length = length ? length + addr : this.eof; + + // Go through until we've reached the end of the text or a stop bit + while ( addr < length ) + { + temp = memory.getUint16( addr ); + addr += 2; + + buffer.push( temp >> 10 & 0x1F, temp >> 5 & 0x1F, temp & 0x1F ); + + // Stop bit + if ( temp & 0x8000 ) + { + break; + } + } + + // Process the Z-chars + while ( i < buffer.length ) + { + zchar = buffer[i++]; + + // Special chars + // Space + if ( zchar === 0 ) + { + result.push( 32 ); + } + // Abbreviations + else if ( zchar < 4 ) + { + usesabbr = 1; + result.push( -1 ); + resulttexts.push( '\uE000+this.abbr(' + ( 32 * ( zchar - 1 ) + buffer[i++] ) + ')+\uE000' ); + } + // Shift characters + else if ( zchar < 6 ) + { + alphabet = zchar; + } + // Check for a 10 bit ZSCII character + else if ( alphabet === 2 && zchar === 6 ) + { + // Check we have enough Z-chars left. + if ( i + 1 < buffer.length ) + { + result.push( buffer[i++] << 5 | buffer[i++] ) + } + } + // Regular characters + else if ( zchar < 0x20 ) + { + result.push( this.alphabets[alphabet][ zchar - 6 ] ); + } + + // Reset the alphabet + alphabet = alphabet < 4 ? 0 : alphabet - 3; + + // Add to the index if we've had raw unicode + if ( ( i % 3 ) === 0 ) + { + i += unicodecount; + unicodecount = 0; + } + } + + result = this.zscii_to_text( result, resulttexts ); + // Abbreviations must be extracted at run time, so return a function instead + if ( usesabbr ) + { + result = { + toString: ( Function( 'return"' + result.replace( /\\/g, '\\\\' ).replace( /"/g, '\\"' ).replace( /\r/g, '\\r' ).replace( /\uE000/g, '"' ) + '"' ) ).bind( this ), + }; + } + // Cache and return + if ( start_addr >= this.staticmem ) + { + this.jit[start_addr] = result; + } + return result; + }, + + // Encode ZSCII into Z-chars + encode: function( zscii ) + { + var alphabets = this.alphabets, + zchars = [], + word_len = this.version3 ? 6 : 9, + i = 0, + achar, + temp, + result = []; + + // Encode the Z-chars + while ( zchars.length < word_len ) + { + achar = zscii[i++]; + // Space + if ( achar === 32 ) + { + zchars.push( 0 ); + } + // Alphabets + else if ( ( temp = alphabets[0].indexOf( achar ) ) >= 0 ) + { + zchars.push( temp + 6 ); + } + else if ( ( temp = alphabets[1].indexOf( achar ) ) >= 0 ) + { + zchars.push( 4, temp + 6 ); + } + else if ( ( temp = alphabets[2].indexOf( achar ) ) >= 0 ) + { + zchars.push( 5, temp + 6 ); + } + // Pad character + else if ( achar === undefined ) + { + zchars.push( 5 ); + } + // 10-bit ZSCII + else + { + zchars.push( 5, 6, achar >> 5, achar & 0x1F ) + } + } + zchars.length = word_len; + + // Encode to bytes + i = 0; + while ( i < word_len ) + { + result.push( zchars[i++] << 2 | zchars[i] >> 3, ( zchars[i++] & 0x07 ) << 5 | zchars[i++] ); + } + result[ result.length - 2 ] |= 0x80; + return result; + }, + + // In these two functions zscii means an array of ZSCII codes and text means a regular Javascript unicode string + zscii_to_text: function( zscii, texts ) + { + var i = 0, l = zscii.length, + charr, + j = 0, + result = ''; + + while ( i < l ) + { + charr = zscii[i++]; + // Text substitution from abbreviations or 1.1 unicode + if ( charr === -1 ) + { + result += texts[j++]; + } + // Regular characters + if ( ( charr = this.unicode_table[charr] ) ) + { + result += charr; + } + } + return result; + }, + + // If the second argument is set then don't use the unicode table + text_to_zscii: function( text, notable ) + { + var array = [], i = 0, l = text.length, charr; + while ( i < l ) + { + charr = text.charCodeAt( i++ ); + // Check the unicode table + if ( !notable ) + { + charr = this.reverse_unicode_table[charr] || 63; + } + array.push( charr ); + } + return array; + }, + + // Parse and cache a dictionary + parse_dict: function( addr ) + { + var memory = this.m, + + addr_start = addr, + dict = {}, + entry_len, + endaddr, + + // Get the word separators + seperators_len = memory.getUint8( addr++ ); + + // Support: IE, Safari, Firefox<38, Chrome<45, Opera<32, Node<4 + // These browsers don't support Uint8Array.indexOf() so convert to a normal array + dict.separators = Array.prototype.slice.call( memory.getUint8Array( addr, seperators_len ) ); + + addr += seperators_len; + + // Go through the dictionary and cache its entries + entry_len = memory.getUint8( addr++ ); + endaddr = addr + 2 + entry_len * memory.getUint16( addr ); + addr += 2; + while ( addr < endaddr ) + { + dict[ Array.prototype.toString.call( memory.getUint8Array( addr, this.version3 ? 4 : 6 ) ) ] = addr; + addr += entry_len; + } + this.dictionaries[addr_start] = dict; + + return dict; + }, + + // Print an abbreviation + abbr: function( abbrnum ) + { + return this.decode( this.m.getUint16( this.abbr_addr + 2 * abbrnum ) * 2 ); + }, + + // Tokenise a text + tokenise: function( bufaddr, parseaddr, dictionary, flag ) + { + // Use the default dictionary if one wasn't provided + dictionary = dictionary || this.dict; + + // Parse the dictionary if needed + dictionary = this.dictionaries[dictionary] || this.parse_dict( dictionary ); + + var memory = this.m, + ram = this.ram, + bufferlength = 1e3, + i = 1, + letter, + separators = dictionary.separators, + word, + words = [], + max_words, + dictword, + wordcount = 0; + + // In versions 5 and 8 we can get the actual buffer length + if ( this.version > 4 ) + { + bufferlength = memory.getUint8( bufaddr + i++ ) + 2; + } + + // Find the words, separated by the separators, but as well as the separators themselves + while ( i < bufferlength ) + { + letter = memory.getUint8( bufaddr + i ); + if ( letter === 0 ) + { + break; + } + else if ( letter === 32 || separators.indexOf( letter ) >= 0 ) + { + if ( letter !== 32 ) + { + words.push( [ [letter], i ] ); + } + word = null; + } + else + { + if ( !word ) + { + words.push( [ [], i ] ); + word = words[ words.length - 1 ][0]; + } + word.push( letter ); + } + i++; + } + + // Go through the text until we either have reached the max number of words, or we're out of words + max_words = Math.min( words.length, memory.getUint8( parseaddr ) ); + while ( wordcount < max_words ) + { + dictword = dictionary['' + this.encode( words[wordcount][0] )]; + + // If the flag is set then don't overwrite words which weren't found + if ( !flag || dictword ) + { + // Fill out the buffer + ram.setUint16( parseaddr + 2 + wordcount * 4, dictword || 0 ); + ram.setUint8( parseaddr + 4 + wordcount * 4, words[wordcount][0].length ); + ram.setUint8( parseaddr + 5 + wordcount * 4, words[wordcount][1] ); + } + wordcount++; + } + + // Update the number of found words + ram.setUint8( parseaddr + 1, wordcount ); + }, + +}; + +},{}]},{},[4])(4) +}); diff --git a/src/Arcade/ui/ArcadeRoot.tsx b/src/Arcade/ui/ArcadeRoot.tsx index 81193a48e8..0843fbb26b 100644 --- a/src/Arcade/ui/ArcadeRoot.tsx +++ b/src/Arcade/ui/ArcadeRoot.tsx @@ -4,14 +4,18 @@ import { BBCabinetRoot } from "./BBCabinet"; import Button from "@mui/material/Button"; import { Player } from "@player"; import { AlertEvents } from "../../ui/React/AlertManager"; +import { ZorkRoot } from "../Zork/ZorkRoot"; +import { ZorkGames, ZorkGame } from "../Zork/metadata"; enum Page { None, Megabyteburner2000, + Zork, } export function ArcadeRoot(): React.ReactElement { const [page, setPage] = useState(Page.None); + const [zorkGame, setZorkGame] = useState(null); function mbBurner2000(): void { if (Player.activeSourceFileLvl(1) === 0) { @@ -21,10 +25,22 @@ export function ArcadeRoot(): React.ReactElement { } } + function playZork(game: ZorkGame): void { + setZorkGame(game); + setPage(Page.Zork); + } + if (page === Page.None) { return ( <> +
+ {ZorkGames.map((game) => ( + + +
+
+ ))} ); } @@ -32,6 +48,10 @@ export function ArcadeRoot(): React.ReactElement { switch (page) { case Page.Megabyteburner2000: currentGame = ; + break; + case Page.Zork: + if (zorkGame) currentGame = ; + break; } return ( <> diff --git a/test/Arcade/Zork/GlkOteReact.test.ts b/test/Arcade/Zork/GlkOteReact.test.ts new file mode 100644 index 0000000000..339aa0ba9f --- /dev/null +++ b/test/Arcade/Zork/GlkOteReact.test.ts @@ -0,0 +1,83 @@ +import { GlkOteReact, TerminalState } from "../../../src/Arcade/Zork/GlkOteReact"; +import { ZorkDialog } from "../../../src/Arcade/Zork/ZorkDialog"; + +function makeGlkOte(): { glkote: GlkOteReact; states: TerminalState[]; accepted: unknown[] } { + const states: TerminalState[] = []; + const accepted: unknown[] = []; + const glkote = new GlkOteReact(new ZorkDialog("test"), (s) => states.push(s)); + glkote.init({ accept: (ev: unknown) => accepted.push(ev) }); + return { glkote, states, accepted }; +} + +describe("GlkOteReact", () => { + it("sends an init event with metrics on init()", () => { + const { accepted } = makeGlkOte(); + expect(accepted).toHaveLength(1); + const ev = accepted[0] as { type: string; gen: number; metrics: { width: number } }; + expect(ev.type).toBe("init"); + expect(ev.metrics.width).toBeGreaterThan(0); + }); + + it("parses grid and buffer content updates (both run formats)", () => { + const { glkote, states } = makeGlkOte(); + glkote.update({ + type: "update", + gen: 1, + windows: [ + { id: 1, type: "grid", rock: 202, gridwidth: 80, gridheight: 1 }, + { id: 2, type: "buffer", rock: 201 }, + ], + content: [ + { id: 1, lines: [{ line: 0, content: ["normal", " West of House"] }] }, + { + id: 2, + text: [ + { content: [{ style: "normal", text: "You are standing in an open field" }] }, + { append: true, content: ["normal", " west of a white house."] }, + ], + }, + ], + input: [{ id: 2, gen: 1, type: "line", maxlen: 200 }], + }); + const s = states[states.length - 1]; + expect(s.gridLines[0].map((r) => r.text).join("")).toContain("West of House"); + expect(s.bufferLines).toHaveLength(1); + expect(s.bufferLines[0].map((r) => r.text).join("")).toBe( + "You are standing in an open field west of a white house.", + ); + expect(s.inputRequest).toMatchObject({ id: 2, type: "line" }); + }); + + it("clears buffer on clear, surfaces file prompts, and echoes line events back", () => { + const { glkote, states, accepted } = makeGlkOte(); + glkote.update({ + type: "update", + gen: 1, + windows: [{ id: 2, type: "buffer", rock: 201 }], + content: [{ id: 2, text: [{ content: ["normal", "old text"] }] }], + input: [{ id: 2, gen: 1, type: "line", maxlen: 200 }], + }); + glkote.sendLine("save"); + const lineEv = accepted[accepted.length - 1] as { type: string; window: number; value: string }; + expect(lineEv).toMatchObject({ type: "line", window: 2, value: "save" }); + + glkote.update({ + type: "update", + gen: 2, + specialinput: { type: "fileref_prompt", filemode: "write", filetype: "save" }, + }); + expect(states[states.length - 1].filePrompt).toMatchObject({ filemode: "write" }); + + glkote.sendFileref({ filename: "slot1", usage: "save" }); + const frEv = accepted[accepted.length - 1] as { type: string; response: string }; + expect(frEv).toMatchObject({ type: "specialresponse", response: "fileref_prompt" }); + + glkote.update({ + type: "update", + gen: 3, + content: [{ id: 2, clear: true, text: [{ content: ["normal", "fresh"] }] }], + }); + const s = states[states.length - 1]; + expect(s.bufferLines.map((l) => l.map((r) => r.text).join("")).join("\n")).toBe("fresh"); + }); +}); diff --git a/test/Arcade/Zork/ZorkDialog.test.ts b/test/Arcade/Zork/ZorkDialog.test.ts new file mode 100644 index 0000000000..9b21e081b7 --- /dev/null +++ b/test/Arcade/Zork/ZorkDialog.test.ts @@ -0,0 +1,34 @@ +import { ZorkDialog } from "../../../src/Arcade/Zork/ZorkDialog"; + +describe("ZorkDialog", () => { + beforeEach(() => localStorage.clear()); + + it("round-trips binary save data through localStorage", () => { + const dialog = new ZorkDialog("zork1"); + const ref = dialog.file_construct_ref("slot1", "save", ""); + expect(dialog.file_ref_exists(ref)).toBe(false); + const bytes = [0, 1, 2, 127, 128, 255, 66]; + expect(dialog.file_write(ref, bytes, true)).toBe(true); + expect(dialog.file_ref_exists(ref)).toBe(true); + expect(dialog.file_read(ref, true)).toEqual(bytes); + expect(localStorage.getItem("zork.zork1.save.slot1")).not.toBeNull(); + }); + + it("keeps games separate and removes refs", () => { + const d1 = new ZorkDialog("zork1"); + const d2 = new ZorkDialog("zork2"); + const ref = d1.file_construct_ref("slot1", "save", ""); + d1.file_write(ref, [1], true); + expect(d2.file_ref_exists(d2.file_construct_ref("slot1", "save", ""))).toBe(false); + d1.file_remove_ref(ref); + expect(d1.file_ref_exists(ref)).toBe(false); + expect(d1.file_read(ref, true)).toBeNull(); + }); + + it("cleans fixed names and constructs temp refs", () => { + const d = new ZorkDialog("zork1"); + expect(d.file_clean_fixed_name('a/b\\c:d"e', "save")).toBe("abcde"); + const temp = d.file_construct_temp_ref("data"); + expect(temp.filename.startsWith("_temp")).toBe(true); + }); +}); diff --git a/test/Arcade/Zork/ZorkTerminal.test.tsx b/test/Arcade/Zork/ZorkTerminal.test.tsx new file mode 100644 index 0000000000..c439cb9c1a --- /dev/null +++ b/test/Arcade/Zork/ZorkTerminal.test.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { act, Simulate } from "react-dom/test-utils"; +import { ZorkTerminal } from "../../../src/Arcade/Zork/ZorkTerminal"; +import type { TerminalState } from "../../../src/Arcade/Zork/GlkOteReact"; + +const baseState: TerminalState = { + gridLines: [[{ style: "normal", text: " West of House Score: 0 Moves: 0" }]], + bufferLines: [ + [{ style: "normal", text: "You are standing in an open field west of a white house." }], + [{ style: "normal", text: "There is a small mailbox here." }], + ], + inputRequest: { id: 2, gen: 1, type: "line", maxlen: 200, initial: "" }, + filePrompt: null, + disabled: false, + error: null, +}; + +describe("ZorkTerminal", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + afterEach(() => { + ReactDOM.unmountComponentAtNode(container); + container.remove(); + }); + + it("renders status line, story text, and an input", () => { + act(() => { + ReactDOM.render( + , + container, + ); + }); + expect(container.textContent).toContain("West of House"); + expect(container.textContent).toContain("small mailbox"); + expect(container.querySelector("input")).not.toBeNull(); + }); + + it("submits a line on Enter", () => { + const onLine = jest.fn(); + act(() => { + ReactDOM.render( + , + container, + ); + }); + const input = container.querySelector("input") as HTMLInputElement; + act(() => { + Simulate.change(input, { target: { value: "open mailbox" } as unknown as EventTarget & HTMLInputElement }); + }); + act(() => { + Simulate.keyDown(input, { key: "Enter" }); + }); + expect(onLine).toHaveBeenCalledWith("open mailbox"); + }); + + it("shows the save-slot prompt when filePrompt is set", () => { + const state: TerminalState = { + ...baseState, + inputRequest: null, + filePrompt: { filemode: "write", filetype: "save" }, + }; + act(() => { + ReactDOM.render( + , + container, + ); + }); + expect(container.textContent).toMatch(/slot|file|name/i); + }); + + it("indicates focus state and refocuses the input when the terminal is clicked", () => { + act(() => { + ReactDOM.render( + , + container, + ); + }); + const root = container.querySelector("[data-focused]") as HTMLElement; + const input = container.querySelector("input") as HTMLInputElement; + act(() => { + input.focus(); + }); + expect(root.getAttribute("data-focused")).toBe("true"); + act(() => { + input.blur(); + }); + expect(root.getAttribute("data-focused")).toBe("false"); + act(() => { + root.click(); + }); + expect(document.activeElement).toBe(input); + expect(root.getAttribute("data-focused")).toBe("true"); + }); +}); diff --git a/test/Arcade/Zork/engine.test.ts b/test/Arcade/Zork/engine.test.ts new file mode 100644 index 0000000000..d86e265de1 --- /dev/null +++ b/test/Arcade/Zork/engine.test.ts @@ -0,0 +1,43 @@ +import * as fs from "fs"; +import * as path from "path"; +import type { TerminalState } from "../../../src/Arcade/Zork/GlkOteReact"; +import { createZorkSession } from "../../../src/Arcade/Zork/session"; + +function textOf(state: TerminalState): string { + return state.bufferLines.map((line) => line.map((r) => r.text).join("")).join("\n"); +} + +describe("Zork I engine smoke test", () => { + beforeEach(() => localStorage.clear()); + + it("boots Zork I, walks the opening, and saves", () => { + const story = new Uint8Array(fs.readFileSync(path.join(__dirname, "../../../src/Arcade/Zork/games/zork1.z3"))); + let state: TerminalState | null = null; + const session = createZorkSession(story, "zork1", (s) => (state = s)); + + // ZVM runs synchronously until the first input request. + expect(state).not.toBeNull(); + let s = state as unknown as TerminalState; + expect(textOf(s)).toContain("West of House"); + expect(textOf(s)).toContain("white house"); + expect(s.inputRequest?.type).toBe("line"); + + session.glkote.sendLine("open mailbox"); + s = state as unknown as TerminalState; + expect(textOf(s)).toContain("leaflet"); + + // Status line (grid window) shows the room. + expect(s.gridLines.map((l) => l.map((r) => r.text).join("")).join("\n")).toContain("West of House"); + + // save -> fileref prompt -> localStorage entry appears. + session.glkote.sendLine("save"); + s = state as unknown as TerminalState; + expect(s.filePrompt?.filemode).toBe("write"); + session.glkote.sendFileref({ filename: "slot1", usage: "save" }); + s = state as unknown as TerminalState; + expect(textOf(s).toLowerCase()).toContain("ok"); + expect(localStorage.getItem("zork.zork1.save.slot1")).not.toBeNull(); + + session.dispose(); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index e69044d401..4dee264d61 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -145,7 +145,7 @@ module.exports = (env, argv) => { }, }, }, - { test: /\.(ttf|woff2|png|jpe?g|gif|jp2|webp|svg)$/, type: "asset/resource" }, + { test: /\.(ttf|woff2|png|jpe?g|gif|jp2|webp|svg|z3)$/, type: "asset/resource" }, { test: /\.s?css$/, use: ["style-loader", "css-loader"],