mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
feat(observatory): contradiction firewall fires on real events (Phase 2)
The crimson quarantine firewall was hard-locked to the deterministic demo
clock. Now it fires on real cognitive events — a MemorySuppressed or a
DeepReferenceCompleted contradiction pair — targeting the REAL memory and
its REAL graph neighbors, on camera.
- firewall-plan: buildLiveFirewallPlan(graph, seed, intruderIndex) reuses
all the deterministic machinery (real-layout shock delays, real severed
neighbors, spine, verdict) but takes the intruder from the live event.
- firewall.wgsl + render-nodes.wgsl: dual-gate on demo_id==4 OR
live_kind==1, driven by live_frame (frames since the event) so the
720-frame beat map plays once from the event, not the loop clock.
- firewall-renderer: rearm(plan) rebuilds the fire buffer for a live
target; compute() gates on demo OR live.
- live-bridge: lazily builds + rearms a live FirewallRenderer on a
firewall event; onFirewall callback surfaces the verdict.
- ObservatoryStage: crimson "threat quarantined" verdict card naming the
real memory, auto-clears after the choreography.
- live lane [13] repurposed liveStartFrame -> liveFrame (event-relative).
Verified live: fired 3 real suppressions on the 1241-memory brain — each
raised the crimson verdict naming the real memory ("ProtocolGate Lane 4
design decision", cascade ~54), the field ran the quarantine, the forget
counter incremented 129->132, then all 3 reversed cleanly. check 0 err,
1033 tests, build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
addbf260f1
commit
d7f5657bc7
13 changed files with 184 additions and 24 deletions
|
|
@ -96,6 +96,11 @@
|
|||
let projectionDays = $state(0);
|
||||
let liveBridge: LiveBridge | null = null;
|
||||
let liveDecayReady = $state(false);
|
||||
// Live contradiction-firewall verdict — set when a real MemorySuppressed /
|
||||
// contradiction event quarantines a memory on camera. Held ~7s then fades.
|
||||
let liveFirewallLabel = $state('');
|
||||
let liveFirewallAt = $state(0);
|
||||
let liveFirewallVisible = $derived(liveFirewallLabel !== '' && liveFirewallAt > 0);
|
||||
|
||||
// GPU picking — screen px → NDC → NodeRenderer.pickAt (one readback/click).
|
||||
let canvasLayerEl: HTMLDivElement | null = $state(null);
|
||||
|
|
@ -297,7 +302,12 @@
|
|||
engine,
|
||||
renderer,
|
||||
graph: renderer.graph,
|
||||
projectionDays: () => projectionDays
|
||||
seed,
|
||||
projectionDays: () => projectionDays,
|
||||
onFirewall: (info) => {
|
||||
liveFirewallLabel = info.intruderLabel;
|
||||
liveFirewallAt = Date.now();
|
||||
}
|
||||
});
|
||||
liveDecayReady = liveBridge.liveDecayAvailable;
|
||||
engine.setPreFrameHook((simFrame) => liveBridge?.drain(simFrame));
|
||||
|
|
@ -324,6 +334,17 @@
|
|||
liveBridge?.refreshDecay();
|
||||
});
|
||||
|
||||
// Auto-clear the live firewall verdict ~7s after it fires (matches the
|
||||
// quarantine choreography length so the card leaves with the crimson ring).
|
||||
$effect(() => {
|
||||
if (!liveFirewallAt) return;
|
||||
const t = setTimeout(() => {
|
||||
liveFirewallLabel = '';
|
||||
liveFirewallAt = 0;
|
||||
}, 7000);
|
||||
return () => clearTimeout(t);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadGraph();
|
||||
});
|
||||
|
|
@ -359,6 +380,28 @@
|
|||
chrome='none' leaves only loading/error (host page owns the chrome). -->
|
||||
{#if showHud}
|
||||
<div class="absolute inset-0 z-10 pointer-events-none">
|
||||
<!-- v2.3 living field — LIVE contradiction firewall verdict. Fires only
|
||||
when a real MemorySuppressed / contradiction event quarantines a
|
||||
memory on camera; the crimson quarantine ring plays on the field
|
||||
itself (firewall.wgsl live path). Crimson tone = the immune response. -->
|
||||
{#if live && liveFirewallVisible}
|
||||
<div
|
||||
class="absolute top-20 left-1/2 -translate-x-1/2 pointer-events-none
|
||||
flex flex-col items-center gap-1 px-5 py-3 rounded-xl border border-[#ff2d55]/40
|
||||
bg-[#1a0508]/85 backdrop-blur-sm text-center enter"
|
||||
>
|
||||
<div class="font-mono text-[11px] tracking-[0.2em] text-[#ff5c78] uppercase">
|
||||
⬤ threat quarantined
|
||||
</div>
|
||||
<div class="font-mono text-[13px] text-[#ffd0d8] max-w-sm truncate">
|
||||
{liveFirewallLabel}
|
||||
</div>
|
||||
<div class="font-mono text-[10px] tracking-wide text-[#ff5c78]/70">
|
||||
memory held in review · Memory PR opened
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- v2.3 living field — forward-projection scrubber. Real per-memory FSRS
|
||||
decay drifts too slowly to watch in one session, so this projects the
|
||||
field N days forward on the SAME true forgetting curve (honest, not
|
||||
|
|
|
|||
|
|
@ -252,6 +252,11 @@ export function firewallEnvelopes(
|
|||
|
||||
const UINTS_PER_STEP = 4;
|
||||
|
||||
/** An idle (viable:false) firewall plan — the field breathes, nothing fires. */
|
||||
export function emptyFirewallPlan(nodeCount: number): FirewallPlan {
|
||||
return emptyPlan(nodeCount);
|
||||
}
|
||||
|
||||
function emptyPlan(nodeCount: number): FirewallPlan {
|
||||
return {
|
||||
viable: false,
|
||||
|
|
@ -276,8 +281,36 @@ function emptyPlan(nodeCount: number): FirewallPlan {
|
|||
* viable:false — the field breathes, nothing pretends to be a threat.
|
||||
*/
|
||||
export function buildFirewallPlan(graph: ObservatoryGraph, seed: string): FirewallPlan {
|
||||
return buildFirewallPlanFor(graph, seed, pickIntruderIndex(graph));
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.3 living field — build a firewall plan for a SPECIFIC intruder (a real
|
||||
* MemorySuppressed target, or a real contradiction-pair member) instead of the
|
||||
* deterministic demo pick. Everything downstream (shock delays from the real
|
||||
* layout, severed real neighbors, spine beats, verdict) is identical — only the
|
||||
* intruder is supplied by the live event. The neighbors are the intruder's REAL
|
||||
* graph edges (pickSeveredNeighbors), so the quarantine ring severs true
|
||||
* connections, never invented ones. Returns viable:false if the intruder isn't
|
||||
* in the field (nothing pretends).
|
||||
*/
|
||||
export function buildLiveFirewallPlan(
|
||||
graph: ObservatoryGraph,
|
||||
seed: string,
|
||||
intruderIndex: number
|
||||
): FirewallPlan {
|
||||
if (intruderIndex < 0 || intruderIndex >= graph.nodes.length) {
|
||||
return emptyPlan(graph.nodes.length);
|
||||
}
|
||||
return buildFirewallPlanFor(graph, seed, intruderIndex);
|
||||
}
|
||||
|
||||
function buildFirewallPlanFor(
|
||||
graph: ObservatoryGraph,
|
||||
seed: string,
|
||||
intruderIndex: number
|
||||
): FirewallPlan {
|
||||
const n = graph.nodes.length;
|
||||
const intruderIndex = pickIntruderIndex(graph);
|
||||
if (n === 0 || intruderIndex < 0) return emptyPlan(n);
|
||||
|
||||
// Shock delays come from the REAL layout (rescue-plan.layoutPositions —
|
||||
|
|
|
|||
|
|
@ -91,9 +91,37 @@ export class FirewallRenderer implements FramePass {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.3 living field — re-arm the firewall for a LIVE event with a fresh plan
|
||||
* (a real MemorySuppressed / contradiction target + its real neighbors).
|
||||
* Rebuilds the fire buffer + bind group so the choreography quarantines the
|
||||
* true intruder. Safe to call any time after upload(); a non-viable plan
|
||||
* disarms (the pass becomes a no-op until the next real event).
|
||||
*/
|
||||
rearm(plan: FirewallPlan): void {
|
||||
this.plan = plan;
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device) return;
|
||||
if (!plan.viable) {
|
||||
this.pipeline = null;
|
||||
this.bindGroup = null;
|
||||
return;
|
||||
}
|
||||
this.upload();
|
||||
}
|
||||
|
||||
/** Whether a viable plan is currently armed (live or demo). */
|
||||
get armed(): boolean {
|
||||
return this.plan.viable && !!this.pipeline && !!this.bindGroup;
|
||||
}
|
||||
|
||||
/** FramePass — overwrite the four demo lanes for this frame (pure of frame). */
|
||||
compute(encoder: GPUCommandEncoder): void {
|
||||
if (this.engine.params[9] !== FIREWALL_DEMO_ID) return;
|
||||
// Fire for the deterministic demo (demo index 4) OR a live firewall event
|
||||
// (live_kind lane == LIVE_KIND.firewall == 1). Both drive the same shader.
|
||||
const isDemo = this.engine.params[9] === FIREWALL_DEMO_ID;
|
||||
const isLive = this.engine.params[12] === 1;
|
||||
if (!isDemo && !isLive) return;
|
||||
if (!this.pipeline || !this.bindGroup) return;
|
||||
const n = this.nodeRenderer.nodeCountValue;
|
||||
if (n === 0) return;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import type { NodeRenderer } from './node-renderer';
|
|||
import type { VestigeEvent } from '$types';
|
||||
import { LIVE_KIND, PARAM_IDX, type ObservatoryGraph } from './types';
|
||||
import { liveRetrievability } from './fsrs';
|
||||
import { FirewallRenderer } from './firewall-renderer';
|
||||
import { buildLiveFirewallPlan, emptyFirewallPlan } from './firewall-plan';
|
||||
|
||||
/** A decoded live event, normalized off the wire's { type, data } envelope. */
|
||||
interface DecodedEvent {
|
||||
|
|
@ -52,6 +54,9 @@ export interface LiveBridgeDeps {
|
|||
engine: ObservatoryEngine;
|
||||
renderer: NodeRenderer;
|
||||
graph: ObservatoryGraph;
|
||||
/** Layout seed (matches NodeRenderer.upload) — the firewall shock delays
|
||||
* come from the REAL layout, so this must be the same seed the field used. */
|
||||
seed: string;
|
||||
/**
|
||||
* Forward-projection scrubber (Phase 1). Days added to every node's real
|
||||
* FSRS elapsed so decay is legible in one viewing session. A getter so the
|
||||
|
|
@ -60,14 +65,22 @@ export interface LiveBridgeDeps {
|
|||
projectionDays?: () => number;
|
||||
/** Dev/verification: mirror what the bridge applied, for assertions. */
|
||||
onApply?: (info: { simFrame: number; activeKind: number; eventsSeen: number }) => void;
|
||||
/** Fired when a live firewall arms — the host can surface a verdict card. */
|
||||
onFirewall?: (info: { intruderLabel: string; startFrame: number }) => void;
|
||||
}
|
||||
|
||||
export class LiveBridge {
|
||||
private engine: ObservatoryEngine;
|
||||
private renderer: NodeRenderer;
|
||||
private graph: ObservatoryGraph;
|
||||
private seed: string;
|
||||
private projectionDays: () => number;
|
||||
private onApply?: LiveBridgeDeps['onApply'];
|
||||
private onFirewall?: LiveBridgeDeps['onFirewall'];
|
||||
|
||||
/** Live firewall pass — constructed lazily on the first firewall event so a
|
||||
* field that never sees a suppression pays nothing. */
|
||||
private firewall: FirewallRenderer | null = null;
|
||||
|
||||
/** id → stable buffer index (from the ObservatoryGraph). */
|
||||
private indexById: Map<string, number>;
|
||||
|
|
@ -90,8 +103,10 @@ export class LiveBridge {
|
|||
this.engine = deps.engine;
|
||||
this.renderer = deps.renderer;
|
||||
this.graph = deps.graph;
|
||||
this.seed = deps.seed;
|
||||
this.projectionDays = deps.projectionDays ?? (() => 0);
|
||||
this.onApply = deps.onApply;
|
||||
this.onFirewall = deps.onFirewall;
|
||||
this.indexById = deps.graph.indexById;
|
||||
|
||||
const n = deps.graph.nodes.length;
|
||||
|
|
@ -105,7 +120,7 @@ export class LiveBridge {
|
|||
// Seed the live lanes to a calm resting state.
|
||||
const p = this.engine.params;
|
||||
p[PARAM_IDX.liveKind] = LIVE_KIND.none;
|
||||
p[PARAM_IDX.liveStartFrame] = 0;
|
||||
p[PARAM_IDX.liveFrame] = 0;
|
||||
p[PARAM_IDX.liveEnergy] = 0;
|
||||
p[PARAM_IDX.projectionDays] = 0;
|
||||
}
|
||||
|
|
@ -248,6 +263,27 @@ export class LiveBridge {
|
|||
private arm(ev: DecodedEvent): void {
|
||||
this.active = ev;
|
||||
this.eventsSeen++;
|
||||
|
||||
// Firewall: build a live plan for the REAL intruder + its real neighbors
|
||||
// and (re)arm the quarantine pass. Lazily construct the renderer on first
|
||||
// use so a field that never sees a suppression pays nothing. Pass order is
|
||||
// safe: the bridge (and thus this renderer) is created AFTER the
|
||||
// NodeRenderer, so firewall_choreo encodes after recall_sim.
|
||||
if (ev.kind === LIVE_KIND.firewall) {
|
||||
const intruderIndex = this.indexById.get(ev.targetId);
|
||||
if (intruderIndex === undefined) return;
|
||||
const plan = buildLiveFirewallPlan(this.graph, this.seed, intruderIndex);
|
||||
if (!plan.viable) return;
|
||||
if (!this.firewall) {
|
||||
this.firewall = new FirewallRenderer({
|
||||
engine: this.engine,
|
||||
nodeRenderer: this.renderer,
|
||||
plan: emptyFirewallPlan(this.graph.nodes.length)
|
||||
});
|
||||
}
|
||||
this.firewall.rearm(plan);
|
||||
this.onFirewall?.({ intruderLabel: plan.verdict.intruderLabel, startFrame: ev.startFrame });
|
||||
}
|
||||
}
|
||||
|
||||
/** Real graph neighbors of a node id (for the firewall quarantine ring). */
|
||||
|
|
@ -293,7 +329,9 @@ export class LiveBridge {
|
|||
p[PARAM_IDX.liveEnergy] = 0;
|
||||
} else {
|
||||
p[PARAM_IDX.liveKind] = this.active.kind;
|
||||
p[PARAM_IDX.liveStartFrame] = this.active.startFrame;
|
||||
// Event-relative frame: the live shader branches replay the
|
||||
// choreography once from here, never riding the wrapped loop.
|
||||
p[PARAM_IDX.liveFrame] = Math.max(0, elapsed);
|
||||
p[PARAM_IDX.liveEnergy] = this.energyEnvelope(this.active, elapsed, openEnded);
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ struct Params {
|
|||
time: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ struct Params {
|
|||
time: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
|
@ -79,8 +79,13 @@ fn firewall_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
|
|||
if (i >= arrayLength(&fire)) {
|
||||
return;
|
||||
}
|
||||
// Belt-and-braces atop the TS gate: firewall is demo index 4.
|
||||
if (params.demo_id != 4.0) {
|
||||
// Fire in TWO modes: the deterministic demo (demo_id == 4, wrapped loop
|
||||
// frame) OR a LIVE event (live_kind == 1, driven by live_frame = frames
|
||||
// since a real MemorySuppressed / contradiction fired). Anything else →
|
||||
// no-op, and other grammars own the lanes.
|
||||
let is_demo = params.demo_id == 4.0;
|
||||
let is_live = params.live_kind == 1.0;
|
||||
if (!is_demo && !is_live) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +95,15 @@ fn firewall_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
|
|||
let is_sever = (packed & 0x200u) != 0u;
|
||||
let k = f32((packed >> 10u) & 0xfu);
|
||||
|
||||
let f = params.frame;
|
||||
// Live mode replays the SAME 720-frame beat map from the event: f =
|
||||
// live_frame (clamped to the loop window), loop_phase derived from it so the
|
||||
// integer-cycle sines still resolve. Demo mode reads the wrapped loop clock.
|
||||
var f = params.frame;
|
||||
var lp = params.loop_phase;
|
||||
if (is_live) {
|
||||
f = clamp(params.live_frame, 0.0, 719.0);
|
||||
lp = f / 720.0;
|
||||
}
|
||||
|
||||
var fy = 0.0;
|
||||
var fw = 0.0;
|
||||
|
|
@ -100,10 +113,10 @@ fn firewall_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
|
|||
// C¹ handoff into the membrane over 330-332 (the rise sweeps the flare
|
||||
// band exactly once — the condensation read is intentional).
|
||||
fy = env(f, 90.0, 96.0, 310.0, 332.0)
|
||||
* (0.55 + 0.45 * sin(TAU * 36.0 * params.loop_phase));
|
||||
* (0.55 + 0.45 * sin(TAU * 36.0 * lp));
|
||||
// Membrane: sustained ring band [2.60..2.90], 12 integer cycles/loop.
|
||||
fy = fy + env(f, 330.0, 352.0, 620.0, 680.0)
|
||||
* (2.75 + 0.15 * sin(TAU * 12.0 * params.loop_phase));
|
||||
* (2.75 + 0.15 * sin(TAU * 12.0 * lp));
|
||||
// Source detonation as the front leaves.
|
||||
fw = env(f, 148.0, 153.0, 162.0, 196.0);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ struct Params {
|
|||
time: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ struct Params {
|
|||
time: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ struct Params {
|
|||
time: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
|
@ -125,11 +125,15 @@ fn vs_main(
|
|||
let dy = node.demo.y;
|
||||
let dz = node.demo.z;
|
||||
let dw = node.demo.w;
|
||||
// The firewall grammar fires for the deterministic demo (demo_id==4) AND for
|
||||
// a LIVE contradiction/suppression event (live_kind==1). Both write the same
|
||||
// demo lanes (firewall.wgsl), so the visual reads identically either way.
|
||||
let firewall_active = params.demo_id == 4.0 || params.live_kind == 1.0;
|
||||
var lane_swell = 0.0;
|
||||
if (params.demo_id == 2.0) {
|
||||
// salience-rescue: searchlight pop, wave shiver, shock bloom.
|
||||
lane_swell = 0.5 * dy + 0.25 * dz + 0.9 * dw;
|
||||
} else if (params.demo_id == 4.0) {
|
||||
} else if (firewall_active) {
|
||||
// firewall: intrusion flare pop (band (0..1]), membrane presence
|
||||
// (band [2.6..2.9] via the range gate), crimson shock bloom.
|
||||
lane_swell = 0.35 * min(dy, 1.0) + 0.3 * smoothstep(1.5, 2.2, dy) + 0.55 * dw;
|
||||
|
|
@ -233,7 +237,7 @@ fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
|
|||
color = color + vec3<f32>(1.00, 0.16, 0.10) * in.demo_yzw.z * (core * 1.9 + halo * 1.1)
|
||||
+ vec3<f32>(1.0, 0.85, 0.8) * core * in.demo_yzw.z * 0.4;
|
||||
}
|
||||
} else if (params.demo_id == 4.0) {
|
||||
} else if (params.demo_id == 4.0 || params.live_kind == 1.0) {
|
||||
// firewall: demo.y carries TWO value bands — intrusion flare (0..1]
|
||||
// and membrane [2.6..2.9] — separated by range, one lane. demo.w is
|
||||
// the crimson shock rim / sever blink. (demo_yzw = y/z/w lanes.)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ struct Params {
|
|||
time: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ struct Params {
|
|||
time: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ struct Params {
|
|||
time: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -112,9 +112,10 @@ export const PATH_KIND = {
|
|||
// DemoClock. All are 0.0 at rest (a calm field), so a build with no live
|
||||
// bridge is pixel-identical to before.
|
||||
// [12] liveKind (LiveKind index — 0 none, see LIVE_KIND below)
|
||||
// [13] liveStartFrame (absolute sim frame the live event fired — its
|
||||
// animation envelope anchor; NOT the wrapped loop
|
||||
// frame, so live events never pop at the 720 seam)
|
||||
// [13] liveFrame (frames SINCE the active live event fired — the
|
||||
// event-relative animation frame the live shader
|
||||
// branches read, so the choreography plays once from
|
||||
// the event instead of riding the wrapped loop clock)
|
||||
// [14] liveEnergy (0..1+ global agitation — dream storm loosens the
|
||||
// sim, firewall shock rings the field, etc.)
|
||||
// [15] projectionDays (Phase 1 forward-scrub: added to every node's real
|
||||
|
|
@ -151,7 +152,7 @@ export const PARAM_IDX = {
|
|||
time: 10,
|
||||
captureMode: 11,
|
||||
liveKind: 12,
|
||||
liveStartFrame: 13,
|
||||
liveFrame: 13,
|
||||
liveEnergy: 14,
|
||||
projectionDays: 15
|
||||
} as const;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue