@frotzinc/zcode-ui 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +27 -2
- package/app/components/AppNav.vue +33 -0
- package/app/components/CopyText.vue +24 -0
- package/app/components/Disassembly.vue +48 -0
- package/app/components/GlobalsView.vue +83 -0
- package/app/components/HexPane.vue +95 -0
- package/app/components/MapView.vue +166 -0
- package/app/components/NotFound.vue +11 -0
- package/app/components/ObjectTree.vue +177 -0
- package/app/components/RefLink.vue +11 -0
- package/app/components/Referrers.vue +45 -0
- package/app/components/SearchPalette.vue +59 -0
- package/app/components/StatTile.vue +15 -0
- package/app/components/ViewHeader.vue +16 -0
- package/app/composables/useHashRoute.ts +47 -0
- package/app/composables/useSymbols.ts +73 -0
- package/app/utils/live.ts +7 -0
- package/app/utils/map-layout.ts +98 -0
- package/app/utils/nav.ts +10 -0
- package/app/utils/story.ts +87 -0
- package/nuxt.config.ts +12 -0
- package/package.json +54 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@frotzinc/zcode-ui`. Versions follow [Semantic Versioning](https://semver.org/). Part of [frotz, Inc.](https://frotzinc.com).
|
|
4
|
+
|
|
5
|
+
## [0.2.0] - 2026-11-08
|
|
6
|
+
|
|
7
|
+
The views light a running machine (frotztrace Phase 4). Without `live` or `breakpoints`, each renders exactly as before.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`Disassembly`**: with `live`, the PC's line is marked (with "current instruction" for screen readers); with `breakpoints`, a gutter button per line (`aria-pressed`) emits `toggleBreakpoint`.
|
|
11
|
+
- **`GlobalsView`**: with `live`, a "Now" column read from the running machine's memory, and the globals changed since the last stop marked "changed".
|
|
12
|
+
- **`MapView`**: with `live`, the player's room (`real_location`, or `location`) is marked and the visited rooms shaded, with "you are here" and "visited" in the list.
|
|
13
|
+
- **`useSymbols().symbolsText`**: the debug file's text as read, so frotzlens can hand it to frotztrace.
|
|
14
|
+
- **`ObjectTree`** and **`HexPane`**: with `live`, what changed is lit, with a "changed" badge or count as its text equivalent. Give `ObjectTree` a story whose objects are read live (`zcode`'s `liveTables`) to show the tree as it is now.
|
|
15
|
+
|
|
16
|
+
## [0.1.1] - 2026-11-08
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- `MachineView` is now the Debugger's own type from `@frotzinc/zmachine` (0.2.0), re-exported; the same shape as the placeholder it replaces. `@frotzinc/zmachine` is a new dependency, for the type.
|
|
20
|
+
|
|
21
|
+
## [0.1.0] - 2026-11-08
|
|
22
|
+
|
|
23
|
+
The first release: frotzlens's story views, moved out of the app (and relicensed MIT by their author) so frotztrace can show a running story with the same components.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **Views:** `ObjectTree`, `GlobalsView`, `MapView`, `SearchPalette`, and `AppNav`, which now takes the app's list of views (`views`, `active`) instead of building frotzlens's.
|
|
27
|
+
- **New, split out of frotzlens's views:** `Disassembly` (from the routine view) and `HexPane` (from the memory view; `v-model:top`, `notes`, `inRange`).
|
|
28
|
+
- **Parts:** `RefLink`, `Referrers`, `ViewHeader`, `CopyText`, `StatTile`, `NotFound`.
|
|
29
|
+
- **Composables and utils:** `useHashRoute`, `useSymbols`, `activeSymbols`, the naming and routing utils (`hex`, `idRoute`, `idLabel`, `routineName`, …), `useLimit`, `storyIndex`, `layoutMap`.
|
|
30
|
+
- **`MachineView`**, the type of the optional `live` prop the views take; a placeholder until `@frotzinc/zmachine` exports the real one. A view given no `live` (or one, in this release) renders as before.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christopher Schweda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @frotzinc/zcode-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Z-machine story views of [frotzlens](https://frotzlens.frotzinc.com), as a Nuxt layer, so that frotzlens (a story file at rest) and frotztrace (a story running under a debugger) show a story with the same components, the same links and the same look. Part of [frotz, Inc.](https://frotzinc.com)
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// nuxt.config.ts
|
|
7
|
+
export default defineNuxtConfig({
|
|
8
|
+
extends: ['@frotzinc/nuxt-base', '@frotzinc/zcode-ui'],
|
|
9
|
+
modules: ['@nuxt/ui'],
|
|
10
|
+
})
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Everything below is auto-imported. The story is a `StoryFile` from [`@frotzinc/zcode`](https://www.npmjs.com/package/@frotzinc/zcode).
|
|
14
|
+
|
|
15
|
+
| | |
|
|
16
|
+
|---|---|
|
|
17
|
+
| **Views** | `ObjectTree` (an ARIA tree), `GlobalsView`, `MapView`, `Disassembly` (one routine, txd's layout, every operand a link), `HexPane` (paged hex with "Go to address"), `SearchPalette` (⌘K / Ctrl+K), `AppNav` (takes the app's own list of views) |
|
|
18
|
+
| **Parts** | `RefLink`, `Referrers`, `ViewHeader`, `CopyText`, `StatTile`, `NotFound` |
|
|
19
|
+
| **Composables** | `useHashRoute` (the view is the URL hash: `#routine/27f8`), `useSymbols` (names from Inform's `gameinfo.dbg`; `activeSymbols`) |
|
|
20
|
+
| **Utils** | `hex`, `idRoute`, `idLabel`, `routineName`, `objectName`, `globalName`, `attributeName`, `propertyName`, `actionName`, `useLimit`, `storyIndex`, `layoutMap`; the types `MachineView` and `NavView` (also importable as `@frotzinc/zcode-ui/utils/live` and `/utils/nav`) |
|
|
21
|
+
|
|
22
|
+
**The look is not here.** Colors, fonts and the family shell (header, footer, About menu) are [`@frotzinc/nuxt-base`](https://www.npmjs.com/package/@frotzinc/nuxt-base)'s, and this layer adds none, so frotzsmith, frotzlens, frotzplay and frotztrace look like one product, dark by default.
|
|
23
|
+
|
|
24
|
+
**`live`.** `ObjectTree`, `GlobalsView`, `MapView`, `Disassembly` and `HexPane` take an optional `live` prop, a `MachineView` (the program counter, the frame, what changed since the last stop). In 0.1 it is accepted and ignored: without it, and with it, a view renders exactly as frotzlens has always shown it. frotztrace's later releases light what changed.
|
|
25
|
+
|
|
26
|
+
Peer dependencies: `nuxt` ^4.6, `@nuxt/ui` ^4.12, `vue` ^3.5.
|
|
27
|
+
|
|
28
|
+
MIT © Christopher Schweda. Extracted from frotzlens (GPL-3.0-or-later), whose author relicensed these files MIT for the move.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { NavView } from '../utils/nav'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The views, left of the content on wide screens and a scrolling row above it on narrow ones. Each
|
|
6
|
+
* app supplies its own list; `active` is the key of the view being shown.
|
|
7
|
+
*/
|
|
8
|
+
const props = defineProps<{ views: NavView[]; active: string }>()
|
|
9
|
+
|
|
10
|
+
// On a narrow screen the nav is a scrolling row: keep the current view in sight.
|
|
11
|
+
const nav = useTemplateRef<HTMLElement>('nav')
|
|
12
|
+
watch(() => props.active, () => nextTick(() => nav.value?.querySelector('[aria-current="page"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' })), { immediate: true })
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<nav ref="nav" aria-label="Views" class="lg:sticky lg:top-20 lg:self-start">
|
|
17
|
+
<ul class="-mx-1 flex gap-1 overflow-x-auto pb-1 lg:mx-0 lg:w-52 lg:flex-col lg:overflow-visible">
|
|
18
|
+
<li v-for="v in views" :key="v.key">
|
|
19
|
+
<a
|
|
20
|
+
:href="`#${v.key}`"
|
|
21
|
+
:aria-current="active === v.key ? 'page' : undefined"
|
|
22
|
+
class="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm transition-colors hover:bg-elevated"
|
|
23
|
+
:class="active === v.key ? 'bg-elevated font-semibold text-highlighted' : 'text-muted'"
|
|
24
|
+
>
|
|
25
|
+
<UIcon :name="v.icon" class="size-4 shrink-0" :class="active === v.key ? 'text-lamp' : ''" aria-hidden="true" />
|
|
26
|
+
{{ v.label }}
|
|
27
|
+
<span v-if="v.count !== undefined" class="data ml-auto pl-2 text-xs text-muted">{{ v.count }}</span>
|
|
28
|
+
<span v-else-if="v.tag" class="ml-auto rounded-full border border-default px-1.5 text-xs text-muted">{{ v.tag }}</span>
|
|
29
|
+
</a>
|
|
30
|
+
</li>
|
|
31
|
+
</ul>
|
|
32
|
+
</nav>
|
|
33
|
+
</template>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/** "Copy as text": the view's section in Ztools' format, made only when asked for. */
|
|
3
|
+
const props = defineProps<{ text: () => string; label?: string }>()
|
|
4
|
+
const status = ref('')
|
|
5
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
6
|
+
|
|
7
|
+
async function copy() {
|
|
8
|
+
try {
|
|
9
|
+
await navigator.clipboard.writeText(props.text())
|
|
10
|
+
status.value = 'Copied'
|
|
11
|
+
} catch {
|
|
12
|
+
status.value = "Couldn't copy: the browser didn't allow it"
|
|
13
|
+
}
|
|
14
|
+
clearTimeout(timer)
|
|
15
|
+
timer = setTimeout(() => (status.value = ''), 2500)
|
|
16
|
+
}
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="flex items-center gap-3">
|
|
21
|
+
<UButton color="neutral" variant="outline" icon="i-lucide-copy" @click="copy">{{ label ?? 'Copy as text' }}</UButton>
|
|
22
|
+
<span role="status" aria-live="polite" class="text-sm text-muted">{{ status }}</span>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { RoutineView } from '@frotzinc/zcode/format'
|
|
3
|
+
import type { MachineView } from '../utils/live'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One routine's disassembly, as txd lays it out: every routine, string, object and dictionary word
|
|
7
|
+
* it uses is a link, and a branch or jump target moves focus to its line. `live` is unused until
|
|
8
|
+
* frotztrace's Phase 4, when it marks the program counter.
|
|
9
|
+
*/
|
|
10
|
+
const props = defineProps<{ view: RoutineView; address: number; live?: MachineView; breakpoints?: ReadonlySet<number> }>()
|
|
11
|
+
/** frotztrace: a line's gutter button was pressed. */
|
|
12
|
+
const emit = defineEmits<{ toggleBreakpoint: [address: number] }>()
|
|
13
|
+
/** The gutter (the PC, breakpoints) only when there's something to show: frotzlens's view is unchanged. */
|
|
14
|
+
const gutter = computed(() => !!props.live || !!props.breakpoints)
|
|
15
|
+
|
|
16
|
+
// Labels: a branch or jump target scrolls to its line and focuses it (the route stays the routine).
|
|
17
|
+
function jump(target: number) {
|
|
18
|
+
const el = document.getElementById(`at-${hex(target)}`)
|
|
19
|
+
el?.scrollIntoView({ block: 'center' })
|
|
20
|
+
el?.focus()
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div class="overflow-x-auto rounded-xl border border-default bg-muted" role="region" aria-label="Disassembly" tabindex="0">
|
|
26
|
+
<div class="data min-w-max p-4 text-sm leading-6">
|
|
27
|
+
<div class="whitespace-pre text-highlighted">{{ props.view.header }}</div>
|
|
28
|
+
<div v-for="(n, i) in props.view.notes" :key="i" class="whitespace-pre text-muted">{{ n }}</div>
|
|
29
|
+
<div aria-hidden="true"> </div>
|
|
30
|
+
<div
|
|
31
|
+
v-for="l in props.view.lines"
|
|
32
|
+
:id="`at-${hex(l.address)}`"
|
|
33
|
+
:key="l.address"
|
|
34
|
+
tabindex="-1"
|
|
35
|
+
class="whitespace-pre rounded focus:bg-elevated"
|
|
36
|
+
:class="live?.pc === l.address ? 'bg-elevated font-semibold' : ''"
|
|
37
|
+
><span v-if="gutter" class="inline-flex w-12 items-center gap-1"><button
|
|
38
|
+
v-if="breakpoints"
|
|
39
|
+
type="button"
|
|
40
|
+
class="size-4 rounded-full border border-default leading-none"
|
|
41
|
+
:class="breakpoints.has(l.address) ? 'bg-[var(--frotz-lamp)]' : 'hover:bg-elevated'"
|
|
42
|
+
:aria-pressed="breakpoints.has(l.address)"
|
|
43
|
+
:aria-label="`Breakpoint at ${hex(l.address)}`"
|
|
44
|
+
@click="emit('toggleBreakpoint', l.address)"
|
|
45
|
+
/><span v-if="live?.pc === l.address" class="text-lamp" aria-hidden="true">▶</span><span v-if="live?.pc === l.address" class="sr-only">current instruction</span></span><span class="text-muted">{{ `${hex(l.address).padStart(5)} ` }}</span><span class="text-lamp">{{ (l.label ? `${l.label}:` : '').padEnd(7) }}</span><template v-for="(p, j) in l.pieces" :key="j"><a v-if="p.id" :href="idRoute(p.id)" class="text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid">{{ p.text }}</a><a v-else-if="p.target !== undefined" :href="`#routine/${hex(address)}`" class="text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid" :aria-label="`${p.text}, the instruction at ${hex(p.target)}`" @click.prevent="jump(p.target)">{{ p.text }}</a><span v-else>{{ p.text }}</span></template></div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</template>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { StoryFile } from '@frotzinc/zcode'
|
|
3
|
+
import type { MachineView } from '../utils/live'
|
|
4
|
+
|
|
5
|
+
/** The 240 global variables: their starting values, names (with a debug file) and the routines that use them. */
|
|
6
|
+
const props = defineProps<{ story: StoryFile; fileName: string; id?: string; live?: MachineView }>()
|
|
7
|
+
|
|
8
|
+
const number = computed(() => (props.id && /^\d+$/.test(props.id) ? Number(props.id) : undefined))
|
|
9
|
+
const global = computed(() => (number.value === undefined ? undefined : props.story.globals[number.value]))
|
|
10
|
+
|
|
11
|
+
const filter = ref('')
|
|
12
|
+
/** Live (frotztrace): the value now, from the running machine's memory. */
|
|
13
|
+
const nowOf = (address: number) => (props.live ? (props.live.memory[address]! << 8) | props.live.memory[address + 1]! : undefined)
|
|
14
|
+
|
|
15
|
+
const rows = computed(() => {
|
|
16
|
+
const s = props.story
|
|
17
|
+
const q = filter.value.trim().toLowerCase()
|
|
18
|
+
return s.globals
|
|
19
|
+
// Uses: each place an instruction reads or writes it, as the detail's "Used by" counts them.
|
|
20
|
+
.map(g => ({ g, name: globalName(g.number), txd: `G${hex(g.number, 2)}`, uses: s.xrefs[g.id]?.from.length ?? 0, now: nowOf(g.address), changed: !!props.live?.changed.globals.has(g.number) }))
|
|
21
|
+
.filter(r => !q || r.name.toLowerCase().includes(q) || r.txd.toLowerCase() === q || hex(r.g.value).includes(q.replace(/^#|0x/, '')))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/** As text: each global, its name if any, and its starting value. */
|
|
25
|
+
const asText = () =>
|
|
26
|
+
props.story.globals
|
|
27
|
+
.map(g => `G${hex(g.number, 2)} ${hex(g.value)}${activeSymbols.value?.globals[g.number] ? ` ${activeSymbols.value.globals[g.number]}` : ''}`)
|
|
28
|
+
.join('\n') + '\n'
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<div v-if="id === undefined" class="flex flex-col gap-6">
|
|
33
|
+
<ViewHeader title="Globals" :eyebrow="fileName" :copy="asText" copy-label="Copy the globals">
|
|
34
|
+
The 240 global variables (G00–Gef), with the value each starts with and the routines that use it.
|
|
35
|
+
<template v-if="!activeSymbols"> Load the game’s debug file to see their names.</template>
|
|
36
|
+
</ViewHeader>
|
|
37
|
+
<UInput v-model="filter" icon="i-lucide-search" placeholder="Search names, G-numbers or values" aria-label="Search globals" class="max-w-md" />
|
|
38
|
+
<div class="overflow-x-auto rounded-xl border border-default">
|
|
39
|
+
<table class="w-full text-sm">
|
|
40
|
+
<caption class="sr-only">Global variables</caption>
|
|
41
|
+
<thead class="bg-muted text-left text-muted">
|
|
42
|
+
<tr>
|
|
43
|
+
<th scope="col" class="px-4 py-2 font-semibold">Global</th>
|
|
44
|
+
<th v-if="activeSymbols" scope="col" class="px-4 py-2 font-semibold">Number</th>
|
|
45
|
+
<th scope="col" class="px-4 py-2 font-semibold">Address</th>
|
|
46
|
+
<th scope="col" class="px-4 py-2 font-semibold">Starts as</th>
|
|
47
|
+
<th v-if="live" scope="col" class="px-4 py-2 font-semibold">Now</th>
|
|
48
|
+
<th scope="col" class="px-4 py-2 text-right font-semibold">Used by</th>
|
|
49
|
+
</tr>
|
|
50
|
+
</thead>
|
|
51
|
+
<tbody>
|
|
52
|
+
<tr v-for="r in rows" :key="r.g.number" class="border-t border-default" :class="r.changed ? 'bg-elevated' : ''">
|
|
53
|
+
<td class="data px-4 py-1.5"><a :href="`#global/${r.g.number}`" class="text-lamp hover:underline">{{ r.name }}</a></td>
|
|
54
|
+
<td v-if="activeSymbols" class="data px-4 py-1.5 text-muted">{{ r.txd }}</td>
|
|
55
|
+
<td class="data px-4 py-1.5 text-muted">{{ hex(r.g.address) }}</td>
|
|
56
|
+
<td class="data px-4 py-1.5">{{ hex(r.g.value) }} <span class="text-muted">({{ r.g.value }})</span></td>
|
|
57
|
+
<td v-if="live" class="data px-4 py-1.5" :class="r.changed ? 'font-semibold text-lamp' : ''">
|
|
58
|
+
{{ hex(r.now!) }} <span class="text-muted">({{ r.now }})</span>
|
|
59
|
+
<span v-if="r.changed" class="ml-2 rounded-full border border-default px-1.5 font-sans text-xs text-lamp">changed</span>
|
|
60
|
+
</td>
|
|
61
|
+
<td class="data px-4 py-1.5 text-right">{{ r.uses }}</td>
|
|
62
|
+
</tr>
|
|
63
|
+
</tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
<p v-if="!rows.length" class="text-muted">{{ story.globals.length ? `No global matches “${filter}”.` : 'This file has no globals table.' }}</p>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div v-else-if="global" class="flex flex-col gap-6">
|
|
70
|
+
<ViewHeader
|
|
71
|
+
:title="globalName(global.number)"
|
|
72
|
+
:eyebrow="`G${hex(global.number, 2)} · variable ${global.number + 16} · at ${hex(global.address)}`"
|
|
73
|
+
:back="{ to: '#globals', label: 'All globals' }"
|
|
74
|
+
/>
|
|
75
|
+
<dl class="grid w-fit grid-cols-[auto_auto] gap-x-6 gap-y-1 text-sm">
|
|
76
|
+
<dt class="text-muted">Starts as</dt>
|
|
77
|
+
<dd class="data">{{ hex(global.value) }} ({{ global.value }})</dd>
|
|
78
|
+
</dl>
|
|
79
|
+
<Referrers :story="story" :id="global.id" from-title="Used by" />
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<NotFound v-else :what="`global ${id}`" back="#globals" back-label="All globals" />
|
|
83
|
+
</template>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { MachineView } from '../utils/live'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A hex view of bytes, a page of rows at a time so a large file stays quick, with "Go to address"
|
|
6
|
+
* and paging. `top` (v-model) is the first row's address. `notes` labels what begins in a row;
|
|
7
|
+
* `inRange` dims the bytes outside the part being shown. `live` (frotztrace) lights the bytes the
|
|
8
|
+
* running machine changed since it last stopped; pass the live memory as `bytes`.
|
|
9
|
+
*/
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
bytes: Uint8Array
|
|
12
|
+
notes?: (from: number, to: number) => string[]
|
|
13
|
+
inRange?: (address: number) => boolean
|
|
14
|
+
live?: MachineView
|
|
15
|
+
}>()
|
|
16
|
+
const top = defineModel<number>('top', { default: 0 })
|
|
17
|
+
|
|
18
|
+
const ROWS = 32
|
|
19
|
+
const size = computed(() => props.bytes.length)
|
|
20
|
+
|
|
21
|
+
/** Jump to a row-aligned address. */
|
|
22
|
+
const goTo = (a: number) => (top.value = Math.max(0, Math.min(Math.floor(a / 16) * 16, Math.max(0, size.value - 1) & ~15)))
|
|
23
|
+
|
|
24
|
+
const rows = computed(() => {
|
|
25
|
+
const b = props.bytes
|
|
26
|
+
const out: { at: number; bytes: number[]; text: string; notes: string[] }[] = []
|
|
27
|
+
for (let r = 0; r < ROWS; r++) {
|
|
28
|
+
const at = top.value + r * 16
|
|
29
|
+
if (at >= b.length) break
|
|
30
|
+
const bytes = [...b.subarray(at, Math.min(at + 16, b.length))]
|
|
31
|
+
out.push({
|
|
32
|
+
at,
|
|
33
|
+
bytes,
|
|
34
|
+
text: bytes.map(x => (x >= 32 && x < 127 ? String.fromCharCode(x) : '.')).join(''),
|
|
35
|
+
notes: props.notes?.(at, at + 16) ?? [],
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
})
|
|
40
|
+
const inPart = (a: number) => props.inRange?.(a) ?? true
|
|
41
|
+
/** Live: how many of the row's bytes changed (the text equivalent of their highlight). */
|
|
42
|
+
const changedIn = (at: number) => { let n = 0; for (let i = 0; i < 16; i++) if (props.live?.changed.addresses.has(at + i)) n++; return n }
|
|
43
|
+
|
|
44
|
+
const jump = ref('')
|
|
45
|
+
const jumpError = ref('')
|
|
46
|
+
function onJump() {
|
|
47
|
+
const text = jump.value.trim().replace(/^(0x|\$)/i, '')
|
|
48
|
+
const a = /^[0-9a-f]+$/i.test(text) ? parseInt(text, 16) : NaN
|
|
49
|
+
if (!size.value) jumpError.value = 'The file is empty: there are no addresses.'
|
|
50
|
+
else if (Number.isNaN(a)) jumpError.value = `“${jump.value}” isn't a hex address.`
|
|
51
|
+
else if (a >= size.value) jumpError.value = `${hex(a)} is past the end of the file (${hex(size.value - 1)}).`
|
|
52
|
+
else {
|
|
53
|
+
jumpError.value = ''
|
|
54
|
+
goTo(a)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<section aria-labelledby="hex-title" class="flex flex-col gap-4">
|
|
61
|
+
<div class="flex flex-wrap items-end justify-between gap-4">
|
|
62
|
+
<h2 id="hex-title" class="font-display text-2xl font-semibold text-highlighted">Bytes</h2>
|
|
63
|
+
<form class="flex items-center gap-2" @submit.prevent="onJump">
|
|
64
|
+
<label for="hex-jump" class="text-sm text-muted">Go to address</label>
|
|
65
|
+
<UInput id="hex-jump" v-model="jump" placeholder="1c97" class="data w-28" :aria-invalid="!!jumpError || undefined" />
|
|
66
|
+
<UButton type="submit" color="neutral" variant="outline">Go</UButton>
|
|
67
|
+
</form>
|
|
68
|
+
</div>
|
|
69
|
+
<p v-if="jumpError" role="alert" class="text-sm text-error">{{ jumpError }}</p>
|
|
70
|
+
<div v-if="!size">
|
|
71
|
+
<p class="text-muted">The file is empty.</p>
|
|
72
|
+
</div>
|
|
73
|
+
<div v-if="size" class="flex flex-wrap items-center gap-2">
|
|
74
|
+
<UButton color="neutral" variant="outline" icon="i-lucide-chevrons-up" :disabled="top === 0" aria-label="Previous page of bytes" @click="goTo(top - ROWS * 16)" />
|
|
75
|
+
<UButton color="neutral" variant="outline" icon="i-lucide-chevrons-down" :disabled="top + ROWS * 16 >= size" aria-label="Next page of bytes" @click="goTo(top + ROWS * 16)" />
|
|
76
|
+
<span class="data text-sm text-muted">{{ hex(top) }}–{{ hex(Math.min(top + ROWS * 16, size) - 1) }} of {{ hex(size - 1) }}</span>
|
|
77
|
+
</div>
|
|
78
|
+
<div v-if="size" class="overflow-x-auto rounded-xl border border-default bg-muted" role="region" aria-label="Bytes" tabindex="0">
|
|
79
|
+
<table class="data min-w-max text-sm leading-6">
|
|
80
|
+
<caption class="sr-only">Bytes from {{ hex(top) }}, 16 to a row</caption>
|
|
81
|
+
<thead class="sr-only">
|
|
82
|
+
<tr><th scope="col">Address</th><th scope="col">Bytes</th><th scope="col">As text</th><th scope="col">Starts here</th></tr>
|
|
83
|
+
</thead>
|
|
84
|
+
<tbody>
|
|
85
|
+
<tr v-for="r in rows" :key="r.at">
|
|
86
|
+
<th scope="row" class="px-4 text-left font-normal text-muted">{{ hex(r.at, 5) }}</th>
|
|
87
|
+
<td class="whitespace-pre px-2"><template v-for="(b, i) in r.bytes" :key="i"><span :class="live?.changed.addresses.has(r.at + i) ? 'rounded bg-[var(--frotz-lamp)] text-[var(--frotz-ground)]' : inPart(r.at + i) ? 'text-highlighted' : 'text-muted'">{{ hex(b, 2) }}</span>{{ i === 7 ? ' ' : i < 15 ? ' ' : '' }}</template></td>
|
|
88
|
+
<td class="whitespace-pre px-4 text-muted">{{ r.text }}</td>
|
|
89
|
+
<td class="whitespace-nowrap px-4 font-sans text-xs text-lamp">{{ [...r.notes, ...(changedIn(r.at) ? [`${changedIn(r.at)} changed`] : [])].join(' · ') }}</td>
|
|
90
|
+
</tr>
|
|
91
|
+
</tbody>
|
|
92
|
+
</table>
|
|
93
|
+
</div>
|
|
94
|
+
</section>
|
|
95
|
+
</template>
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { roomGraph, toMapGraph, type RoomExit, type StoryFile } from '@frotzinc/zcode'
|
|
3
|
+
import type { MachineView } from '../utils/live'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The map of an Inform game, from its rooms' exits (which need the debug file
|
|
7
|
+
* to be found). Drawn on a grid, as frotzsmith's map is, and listed as text
|
|
8
|
+
* below — the list is the map for anyone who can't see the picture.
|
|
9
|
+
*/
|
|
10
|
+
const props = defineProps<{ story: StoryFile; fileName: string; live?: MachineView }>()
|
|
11
|
+
const { symbols } = useSymbols()
|
|
12
|
+
|
|
13
|
+
const graph = computed(() => (symbols.value ? roomGraph(props.story, symbols.value) : undefined))
|
|
14
|
+
const map = computed(() => (graph.value ? layoutMap(graph.value) : undefined))
|
|
15
|
+
|
|
16
|
+
const CELL = 170
|
|
17
|
+
const W = 136
|
|
18
|
+
const H = 52
|
|
19
|
+
const PAD = 24
|
|
20
|
+
const box = computed(() => {
|
|
21
|
+
const b = map.value?.bounds ?? { minCol: 0, maxCol: 0, minRow: 0, maxRow: 0 }
|
|
22
|
+
const x = b.minCol * CELL - W / 2 - PAD
|
|
23
|
+
const y = b.minRow * CELL - H / 2 - PAD
|
|
24
|
+
return { x, y, w: (b.maxCol - b.minCol) * CELL + W + PAD * 2, h: (b.maxRow - b.minRow) * CELL + H + PAD * 2 }
|
|
25
|
+
})
|
|
26
|
+
const at = computed(() => new Map(map.value?.rooms.map(r => [r.number, r]) ?? []))
|
|
27
|
+
const centre = (n: number) => {
|
|
28
|
+
const r = at.value.get(n)!
|
|
29
|
+
return { x: r.col * CELL, y: r.row * CELL }
|
|
30
|
+
}
|
|
31
|
+
const short = (s: string) => (s.length > 18 ? `${s.slice(0, 17)}…` : s)
|
|
32
|
+
|
|
33
|
+
const DIRS: Record<string, string> = {
|
|
34
|
+
n: 'north', s: 'south', e: 'east', w: 'west', ne: 'northeast', nw: 'northwest', se: 'southeast', sw: 'southwest',
|
|
35
|
+
u: 'up', d: 'down', in: 'in', out: 'out',
|
|
36
|
+
}
|
|
37
|
+
// A room's label: its short name, with its number when another room has the same name (as toMapGraph does).
|
|
38
|
+
const nameCount = computed(() => {
|
|
39
|
+
const m = new Map<string, number>()
|
|
40
|
+
for (const r of graph.value?.rooms ?? []) m.set(objectName(props.story, r.number), (m.get(objectName(props.story, r.number)) ?? 0) + 1)
|
|
41
|
+
return m
|
|
42
|
+
})
|
|
43
|
+
const name = (n: number) => {
|
|
44
|
+
const plain = objectName(props.story, n)
|
|
45
|
+
return (nameCount.value.get(plain) ?? 0) > 1 ? `${plain} (${n})` : plain
|
|
46
|
+
}
|
|
47
|
+
function exitText(e: RoomExit): string {
|
|
48
|
+
if (e.kind === 'room') return ''
|
|
49
|
+
if (e.kind === 'door') return `through ${name(e.door!)}`
|
|
50
|
+
if (e.kind === 'routine') return e.candidates?.length ? 'decided as the game runs: perhaps' : 'decided as the game runs'
|
|
51
|
+
return 'no way through (a message)'
|
|
52
|
+
}
|
|
53
|
+
// Each room's exits, indexed once (a big map would otherwise rescan every exit per room).
|
|
54
|
+
const exitIndex = computed(() => {
|
|
55
|
+
const m = new Map<number, RoomExit[]>()
|
|
56
|
+
for (const e of graph.value?.exits ?? []) m.set(e.from, [...(m.get(e.from) ?? []), e])
|
|
57
|
+
return m
|
|
58
|
+
})
|
|
59
|
+
const exitsFrom = (n: number) => exitIndex.value.get(n) ?? []
|
|
60
|
+
const LINK = 'text-lamp underline decoration-dotted underline-offset-4 hover:decoration-solid'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Live (frotztrace): the player's room and the rooms visited (the `visited` attribute), as they are
|
|
64
|
+
* now. The room is `real_location` when the library has it: in the dark, `location` is the
|
|
65
|
+
* darkness object, not a room.
|
|
66
|
+
*/
|
|
67
|
+
const here = computed(() => {
|
|
68
|
+
const s = symbols.value
|
|
69
|
+
if (!props.live || !s) return undefined
|
|
70
|
+
const named = (name: string) => Object.entries(s.globals).find(([, n]) => n === name)?.[0]
|
|
71
|
+
const g = named('real_location') ?? named('location')
|
|
72
|
+
if (g === undefined) return undefined
|
|
73
|
+
const a = props.story.header.globals + Number(g) * 2
|
|
74
|
+
return (props.live.memory[a]! << 8) | props.live.memory[a + 1]!
|
|
75
|
+
})
|
|
76
|
+
const visited = computed(() => {
|
|
77
|
+
const s = symbols.value
|
|
78
|
+
const attr = s && Object.entries(s.attributes).find(([, n]) => n === 'visited')?.[0]
|
|
79
|
+
if (!props.live || attr === undefined) return new Set<number>()
|
|
80
|
+
return new Set(props.story.objects.objects.filter(o => o.attributes.includes(Number(attr))).map(o => o.number))
|
|
81
|
+
})
|
|
82
|
+
const copy = () => JSON.stringify(toMapGraph(graph.value!), null, 2) + '\n'
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<template>
|
|
86
|
+
<div class="flex flex-col gap-6">
|
|
87
|
+
<ViewHeader title="Map" :eyebrow="fileName" :copy="graph ? copy : undefined" copy-label="Copy the map (frotzsmith's shape)">
|
|
88
|
+
The rooms and the exits between them, from each room’s direction properties. Exits worked out as the
|
|
89
|
+
game runs are drawn to the rooms their routine can return, dashed.
|
|
90
|
+
</ViewHeader>
|
|
91
|
+
|
|
92
|
+
<div v-if="!symbols" class="flex flex-col gap-3 rounded-xl border border-default p-5">
|
|
93
|
+
<p>The map needs the game’s debug file: it finds exits by their property names (<code class="data">n_to</code>, <code class="data">s_to</code>, …).</p>
|
|
94
|
+
<a href="#symbols" class="w-fit text-lamp hover:underline">Load a debug file</a>
|
|
95
|
+
</div>
|
|
96
|
+
<p v-else-if="!graph" class="text-muted">This game has no direction properties to find exits by.</p>
|
|
97
|
+
|
|
98
|
+
<template v-else-if="map">
|
|
99
|
+
<p class="text-sm text-muted">
|
|
100
|
+
{{ graph.rooms.length }} {{ graph.rooms.length === 1 ? 'room' : 'rooms' }}<template v-if="graph.start">; the game starts in {{ name(graph.start) }}</template>.
|
|
101
|
+
</p>
|
|
102
|
+
<div class="overflow-x-auto rounded-xl border border-default bg-muted">
|
|
103
|
+
<svg
|
|
104
|
+
:viewBox="`${box.x} ${box.y} ${box.w} ${box.h}`"
|
|
105
|
+
:width="box.w"
|
|
106
|
+
:height="box.h"
|
|
107
|
+
class="mx-auto block max-w-none"
|
|
108
|
+
role="group"
|
|
109
|
+
:aria-label="`A map of ${graph.rooms.length} rooms; each links to its object. The list below gives each room's exits.`"
|
|
110
|
+
>
|
|
111
|
+
<g aria-hidden="true">
|
|
112
|
+
<line
|
|
113
|
+
v-for="(c, i) in map.connectors"
|
|
114
|
+
:key="i"
|
|
115
|
+
:x1="centre(c.from).x"
|
|
116
|
+
:y1="centre(c.from).y"
|
|
117
|
+
:x2="centre(c.to).x"
|
|
118
|
+
:y2="centre(c.to).y"
|
|
119
|
+
stroke="var(--frotz-lamp-text)"
|
|
120
|
+
stroke-width="2"
|
|
121
|
+
:stroke-dasharray="c.routine ? '6 5' : undefined"
|
|
122
|
+
stroke-opacity="0.7"
|
|
123
|
+
/>
|
|
124
|
+
</g>
|
|
125
|
+
<a v-for="r in map.rooms" :key="r.number" :href="`#object/${r.number}`">
|
|
126
|
+
<title>{{ name(r.number) }}</title>
|
|
127
|
+
<rect
|
|
128
|
+
:x="r.col * CELL - W / 2"
|
|
129
|
+
:y="r.row * CELL - H / 2"
|
|
130
|
+
:width="W"
|
|
131
|
+
:height="H"
|
|
132
|
+
rx="8"
|
|
133
|
+
:fill="r.number === here ? 'var(--frotz-lamp)' : visited.has(r.number) ? 'var(--ui-bg-accented)' : 'var(--ui-bg-elevated)'"
|
|
134
|
+
:stroke="r.number === here || r.number === graph.start ? 'var(--frotz-lamp-text)' : 'var(--ui-border-accented)'"
|
|
135
|
+
:stroke-width="r.number === here ? 4 : r.number === graph.start ? 3 : 1.5"
|
|
136
|
+
/>
|
|
137
|
+
<text :x="r.col * CELL" :y="r.row * CELL + 5" text-anchor="middle" font-size="14" :fill="r.number === here ? 'var(--frotz-ground)' : 'var(--ui-text-highlighted)'">{{ short(name(r.number)) }}</text>
|
|
138
|
+
</a>
|
|
139
|
+
</svg>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<section aria-labelledby="rooms-title" class="flex flex-col gap-3">
|
|
143
|
+
<h2 id="rooms-title" class="font-display text-xl font-semibold text-highlighted">Rooms and exits</h2>
|
|
144
|
+
<ul class="flex flex-col gap-3 text-sm">
|
|
145
|
+
<li v-for="r in graph.rooms" :key="r.number">
|
|
146
|
+
<a :href="`#object/${r.number}`" class="font-semibold text-lamp hover:underline">{{ name(r.number) }}</a>
|
|
147
|
+
<span v-if="r.number === graph.start" class="ml-2 text-xs text-muted">where the game starts</span>
|
|
148
|
+
<span v-if="r.number === here" class="ml-2 rounded-full border border-default px-1.5 text-xs text-lamp">you are here</span>
|
|
149
|
+
<span v-else-if="visited.has(r.number)" class="ml-2 text-xs text-muted">visited</span>
|
|
150
|
+
<ul v-if="exitsFrom(r.number).length" class="mt-1 flex flex-col gap-0.5 pl-4">
|
|
151
|
+
<li v-for="(e, i) in exitsFrom(r.number)" :key="i">
|
|
152
|
+
<span class="data inline-block w-24 text-muted">{{ DIRS[e.dir] }}</span>
|
|
153
|
+
{{ exitText(e) }}
|
|
154
|
+
<!-- Links inside a sentence are underlined, so they don't rely on colour alone (WCAG 1.4.1). -->
|
|
155
|
+
<template v-if="e.to !== undefined"><template v-if="e.kind === 'door'"> to </template><a :href="`#object/${e.to}`" :class="LINK">{{ name(e.to) }}</a></template>
|
|
156
|
+
<template v-for="(c, j) in e.kind === 'routine' ? e.candidates ?? [] : []" :key="c">{{ j ? ' or ' : ' ' }}<a :href="`#object/${c}`" :class="LINK">{{ name(c) }}</a></template>
|
|
157
|
+
<template v-if="e.kind === 'routine'"> (<a :href="`#routine/${hex(e.routine!)}`" :class="LINK">{{ routineName(story, e.routine!) }}</a>)</template>
|
|
158
|
+
</li>
|
|
159
|
+
</ul>
|
|
160
|
+
<p v-else class="pl-4 text-muted">No exits.</p>
|
|
161
|
+
</li>
|
|
162
|
+
</ul>
|
|
163
|
+
</section>
|
|
164
|
+
</template>
|
|
165
|
+
</div>
|
|
166
|
+
</template>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{ what: string; back?: string; backLabel?: string }>()
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<a :href="back ?? '#overview'" class="w-fit text-sm text-muted hover:underline">← {{ backLabel ?? 'Overview' }}</a>
|
|
8
|
+
<h1 class="font-display text-3xl font-bold text-highlighted">Nothing here</h1>
|
|
9
|
+
<p class="max-w-2xl text-muted">There's no {{ what }} in this file. ⌘K / Ctrl+K finds any routine, object, string or dictionary word by name or address.</p>
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|