initial commit
This commit is contained in:
123
app/components/HeaderBar.vue
Normal file
123
app/components/HeaderBar.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
const props = defineProps<{
|
||||
onPick: (event: Event) => void
|
||||
onClear: () => Promise<void>
|
||||
showsClear: boolean
|
||||
}>();
|
||||
const config = useRuntimeConfig()
|
||||
const homeUrl = config.public.urlBase;
|
||||
const iconUrl = "/img/icon_civcore.webp"
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bar">
|
||||
<div class="brand">
|
||||
<img :src="iconUrl"/>
|
||||
<span class="mark mono">[<NuxtLink :href="homeUrl" style="color: inherit; text-decoration: none;"><i>UE4SS Object Explorer</i></NuxtLink>]</span>
|
||||
</div>
|
||||
|
||||
<slot/>
|
||||
|
||||
<div class="actions">
|
||||
<label class="load">
|
||||
<input type="file" accept=".txt,.log,.dump" hidden @change="onPick" />
|
||||
<span>Load dump</span>
|
||||
</label>
|
||||
|
||||
<button v-if="showsClear" type="button" class="load clear" @click="onClear">
|
||||
Clear dump
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 14px;
|
||||
height: 46px;
|
||||
flex: none;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.mark {
|
||||
color: var(--dimmer);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mark i {
|
||||
color: var(--amber);
|
||||
font-style: normal;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.readout {
|
||||
font-size: 11px;
|
||||
color: var(--dim);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.readout .file {
|
||||
color: var(--ink);
|
||||
}
|
||||
.sep {
|
||||
color: var(--dimmer);
|
||||
}
|
||||
.warn {
|
||||
color: var(--t-bool);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.load {
|
||||
flex: none;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
padding: 6px 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.load:hover {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.clear {
|
||||
color: var(--dim);
|
||||
}
|
||||
.clear:hover {
|
||||
color: var(--t-bool);
|
||||
border-color: var(--t-bool);
|
||||
}
|
||||
|
||||
</style>
|
||||
269
app/components/ObjectTable.vue
Normal file
269
app/components/ObjectTable.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import type { RowView } from '../composables/useDump'
|
||||
import { hue, abbreviate } from '../lib/type-hue'
|
||||
|
||||
const props = defineProps<{
|
||||
total: number
|
||||
sharedDigits: number
|
||||
fetchWindow: (start: number, end: number) => Promise<{ start: number; rows: RowView[] }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'inspect', row: RowView): void }>()
|
||||
|
||||
const ROW_H = 26
|
||||
const OVERSCAN = 24
|
||||
|
||||
const viewport = ref<HTMLElement | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const height = ref(600)
|
||||
const buffer = ref<{ start: number; rows: RowView[] }>({ start: 0, rows: [] })
|
||||
const selected = ref(-1)
|
||||
let generation = 0
|
||||
|
||||
const firstVisible = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - OVERSCAN))
|
||||
const lastVisible = computed(() =>
|
||||
Math.min(props.total, Math.ceil((scrollTop.value + height.value) / ROW_H) + OVERSCAN),
|
||||
)
|
||||
|
||||
async function refill() {
|
||||
const g = ++generation
|
||||
const { start, rows } = await props.fetchWindow(firstVisible.value, lastVisible.value)
|
||||
if (g === generation) buffer.value = { start, rows }
|
||||
}
|
||||
|
||||
let queued = false
|
||||
function onScroll() {
|
||||
scrollTop.value = viewport.value?.scrollTop ?? 0
|
||||
if (queued) return
|
||||
queued = true
|
||||
requestAnimationFrame(() => {
|
||||
queued = false
|
||||
refill()
|
||||
})
|
||||
}
|
||||
|
||||
function measure() {
|
||||
height.value = viewport.value?.clientHeight ?? 600
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
measure()
|
||||
new ResizeObserver(measure).observe(viewport.value!)
|
||||
refill()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.total,
|
||||
() => {
|
||||
if (viewport.value) viewport.value.scrollTop = 0
|
||||
scrollTop.value = 0
|
||||
selected.value = -1
|
||||
refill()
|
||||
},
|
||||
)
|
||||
|
||||
const visible = computed(() => {
|
||||
const { start, rows } = buffer.value
|
||||
const out: { i: number; row: RowView | null }[] = []
|
||||
for (let i = firstVisible.value; i < lastVisible.value; i++) {
|
||||
out.push({ i, row: rows[i - start] ?? null })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const hex = (n: number) => n.toString(16).toUpperCase().padStart(12, '0')
|
||||
|
||||
/**
|
||||
* Show the tail of a long path, which is the part that identifies it.
|
||||
* Done in JS rather than with `direction: rtl`, because these strings are
|
||||
* full of slashes and colons — neutral characters that bidi reordering
|
||||
* happily moves to the wrong end.
|
||||
*/
|
||||
function tail(s: string, max = 64) {
|
||||
return s.length <= max ? s : '…' + s.slice(s.length - max + 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table">
|
||||
<header class="head mono">
|
||||
<span class="col-addr">ADDRESS</span>
|
||||
<span class="col-type">TYPE</span>
|
||||
<span class="col-name">NAME</span>
|
||||
<span class="col-path">CONTAINER</span>
|
||||
<span class="col-off">OFF</span>
|
||||
</header>
|
||||
|
||||
<div ref="viewport" class="viewport" @scroll.passive="onScroll" tabindex="0">
|
||||
<div class="spacer" :style="{ height: total * ROW_H + 'px' }">
|
||||
<div
|
||||
v-for="cell in visible"
|
||||
:key="cell.i"
|
||||
class="row mono"
|
||||
:class="{ sel: selected === cell.i, pending: !cell.row }"
|
||||
:style="{ top: cell.i * ROW_H + 'px' }"
|
||||
@click="cell.row && ((selected = cell.i), emit('inspect', cell.row))"
|
||||
>
|
||||
<template v-if="cell.row">
|
||||
<span class="col-addr">
|
||||
<i class="ghost">{{ hex(cell.row.address).slice(0, sharedDigits) }}</i
|
||||
>{{ hex(cell.row.address).slice(sharedDigits) }}
|
||||
</span>
|
||||
<span
|
||||
class="col-type"
|
||||
:style="{ color: hue(cell.row.type) }"
|
||||
:title="cell.row.type"
|
||||
>
|
||||
<i class="tick" :style="{ background: hue(cell.row.type) }" />
|
||||
{{ abbreviate(cell.row.type) }}
|
||||
</span>
|
||||
<span class="col-name">{{ cell.row.name }}</span>
|
||||
<span class="col-path" :title="cell.row.path">{{ tail(cell.row.container) }}</span>
|
||||
<span class="col-off">{{
|
||||
cell.row.offset < 0 ? '' : '0x' + cell.row.offset.toString(16).toUpperCase()
|
||||
}}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="total === 0" class="empty">
|
||||
No objects match. Widen the search, or clear the path filter in the spine.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 0 12px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--dim);
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--rule-soft);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--panel);
|
||||
}
|
||||
.row.sel {
|
||||
background: var(--raise);
|
||||
box-shadow: inset 2px 0 0 var(--amber);
|
||||
}
|
||||
.row.pending {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
/* Signature: shared address digits recede, the differing tail stays lit. */
|
||||
.col-addr {
|
||||
width: var(--gutter-w);
|
||||
flex: none;
|
||||
color: var(--amber);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.ghost {
|
||||
color: var(--dimmer);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
width: 138px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tick {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 10px;
|
||||
vertical-align: -1px;
|
||||
margin-right: 5px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
width: 300px;
|
||||
flex: none;
|
||||
color: var(--ink-strong);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.col-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.col-off {
|
||||
width: 56px;
|
||||
flex: none;
|
||||
text-align: right;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
color: var(--dim);
|
||||
font-size: 12px;
|
||||
max-width: 28ch;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.col-name {
|
||||
width: 180px;
|
||||
}
|
||||
.col-off,
|
||||
.col-path {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
213
app/components/PathTree.vue
Normal file
213
app/components/PathTree.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import type { TreeChild } from '../lib/dump-store'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
fetchChildren: (prefix: string) => Promise<{ prefix: string; children: TreeChild[] }>
|
||||
version: number
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
|
||||
|
||||
interface Node extends TreeChild {
|
||||
depth: number
|
||||
open: boolean
|
||||
children: Node[] | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const roots = ref<Node[]>([])
|
||||
|
||||
function toNode(c: TreeChild, depth: number): Node {
|
||||
return { ...c, depth, open: false, children: null, loading: false }
|
||||
}
|
||||
|
||||
async function loadRoots() {
|
||||
roots.value = []
|
||||
const { children } = await props.fetchChildren('')
|
||||
roots.value = children.map((c) => toNode(c, 0))
|
||||
if (roots.value.length === 1 && roots.value[0]) toggle(roots.value[0])
|
||||
}
|
||||
|
||||
async function toggle(node: Node) {
|
||||
if (node.open) {
|
||||
node.open = false
|
||||
return
|
||||
}
|
||||
if (!node.children) {
|
||||
node.loading = true
|
||||
const { children } = await props.fetchChildren(node.path)
|
||||
node.children = children.map((c) => toNode(c, node.depth + 1))
|
||||
node.loading = false
|
||||
}
|
||||
node.open = true
|
||||
}
|
||||
|
||||
function flatten(nodes: Node[], out: Node[] = []): Node[] {
|
||||
for (const n of nodes) {
|
||||
out.push(n)
|
||||
if (n.open && n.children) flatten(n.children, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const fmt = new Intl.NumberFormat()
|
||||
onMounted(loadRoots)
|
||||
watch(() => props.version, loadRoots)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="spine">
|
||||
<div class="spine-head">
|
||||
<span class="eyebrow">Script path</span>
|
||||
<button v-if="modelValue" class="clear" @click="emit('update:modelValue', '')">clear</button>
|
||||
</div>
|
||||
|
||||
<div class="scroll">
|
||||
<button
|
||||
class="node mono root"
|
||||
:class="{ active: modelValue === '' }"
|
||||
@click="emit('update:modelValue', '')"
|
||||
>
|
||||
<span class="seg">all objects</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-for="node in flatten(roots)"
|
||||
:key="node.path"
|
||||
class="node mono"
|
||||
:class="{ active: modelValue === node.path }"
|
||||
:style="{ paddingLeft: 8 + node.depth * 13 + 'px' }"
|
||||
>
|
||||
<button
|
||||
class="twist"
|
||||
:class="{ open: node.open, spin: node.loading }"
|
||||
@click="toggle(node)"
|
||||
:aria-label="node.open ? 'Collapse' : 'Expand'"
|
||||
>
|
||||
{{ node.count > 1 ? '›' : '·' }}
|
||||
</button>
|
||||
<button class="pick" @click="emit('update:modelValue', node.path)">
|
||||
<span class="sep">{{ node.separator }}</span
|
||||
><span class="seg">{{ node.segment }}</span>
|
||||
<span v-if="node.isObject" class="dot" title="An object exists at this exact path" />
|
||||
<span class="count">{{ fmt.format(node.count) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.spine {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
flex: none;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--rule);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.spine-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.clear {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.scroll {
|
||||
overflow: auto;
|
||||
padding: 6px 0 20px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
background: var(--raise);
|
||||
}
|
||||
.node.active {
|
||||
background: var(--amber-soft);
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.node.root {
|
||||
padding-left: 21px;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.twist {
|
||||
width: 13px;
|
||||
flex: none;
|
||||
color: var(--dim);
|
||||
transition: transform 0.12s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
.twist.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.twist.spin {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--dimmer);
|
||||
flex: none;
|
||||
}
|
||||
.seg {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--t-container);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--dimmer);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.spine {
|
||||
width: 100%;
|
||||
max-height: 40vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user