diff --git a/content/toys/triscit/binutils.mjs b/content/toys/triscit/binutils.mjs
new file mode 100644
index 0000000..6ae24c4
--- /dev/null
+++ b/content/toys/triscit/binutils.mjs
@@ -0,0 +1,92 @@
+const glyphs = [
+ "\\x00", "\\x01", "\\x02", "\\x03", "\\x04", "\\x05", "\\x06", "\\x07",
+ "\\x08", "\\x09", "\\x0A", "\\x0B", "\\x0C", "\\x0D", "\\x0E", "\\x0F",
+ "⏵", "⏴", "↕", "‼", "¶", "§", "‽", "↨", "↑", "↓", "→", "←", "∟", "↔", "⏶", "⏷",
+ " ", "!", "\"", "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/",
+ "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ":", ";", "<", "=", ">", "?",
+ "@", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O",
+ "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "[", "\\", "]", "^", "_",
+ "`", "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o",
+ "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "{", "|", "}", "~", "⌂",
+
+ "Ç", "ü", "é", "â", "ä", "à", "å", "ç", "ê", "ë", "è", "ï", "î", "ì", "Ä", "Å",
+ "É", "æ", "Æ", "ô", "ö", "ò", "û", "ù", "ÿ", "Ö", "Ü", "¢", "£", "¥", "₧", "ƒ",
+ "á", "í", "ó", "ú", "ñ", "Ñ", "ª", "º", "¿", "⌐", "¬", "½", "¼", "¡", "«", "»",
+ "░", "▒", "▓", "│", "┤", "╡", "╢", "╖", "╕", "╣", "║", "╗", "╝", "╜", "╛", "┐",
+ "└", "┴", "┬", "├", "─", "┼", "╞", "╟", "╚", "╔", "╩", "╦", "╠", "═", "╬", "╧",
+ "╨", "╤", "╥", "╙", "╘", "╒", "╓", "╫", "╪", "┘", "┌", "█", "▄", "▌", "▐", "▀",
+ "α", "ß", "Γ", "π", "Σ", "σ", "µ", "τ", "Φ", "Θ", "Ω", "δ", "∞", "φ", "ε", "∩",
+ "≡", "±", "≥", "≤", "⌠", "⌡", "÷", "≈", "°", "∞", "⊻", "√", "ⁿ", "²", "■", "¤",
+]
+
+/**
+ * Encode a buffer using fluffy glyphs
+ *
+ * @param {Uint8Array} buf Buffer to encode
+ * @returns {String} Glyph-encoded string
+ */
+function Stringify(buf) {
+ let ret = []
+ for (let i of buf) {
+ ret.push(glyphs[i])
+ }
+ return ret.join("")
+}
+
+
+/**
+ * Hex encode a buffer
+ *
+ * @param {Uint8Array} buf Buffer to encode
+ * @returns {String} Hexlified version
+ */
+function Hexlify(buf) {
+ let ret = []
+ for (let b of buf) {
+ let hex = "00" + b.toString(16)
+ ret.push(hex.slice(-2))
+ }
+ return ret.join(" ")
+}
+
+/**
+ * Hex decode a string
+ *
+ * @param {String} str String to decode
+ * @returns {Uint8Array} Decoded array
+ */
+function Unhexlify(str) {
+ let a = []
+ let match = str.match(/[0-9a-fA-F]{2}/g) || []
+ for (let m of match) {
+ a.push(parseInt(m, 16))
+ }
+ return new Uint8Array(a)
+}
+
+/**
+ * Read a NULL-Terminated string from a buffer.
+ *
+ * @param {Uint8Arrary} buf Buffer to read
+ * @param {Boolean} includeNull True to include the trailing NUL
+ * @returns {Uint8Array} String
+ */
+function CString(buf, includeNull=false) {
+ let end = buf.indexOf(0)
+ if (end == -1) {
+ return buf
+ }
+ return buf.slice(0, end + (includeNull?1:0))
+}
+
+/**
+ * Unescapes a string with things like "\x02" in it.
+ *
+ * @param {String} str String to unescape
+ * @returns {String} Unescaped string
+ */
+function Unescape(str) {
+ return str.replaceAll(/\\x?([0-9]+)/g, (_, p1) => String.fromCharCode(p1))
+}
+
+export {Stringify, Hexlify, Unhexlify, CString, Unescape}
diff --git a/content/toys/triscit/fire.gif b/content/toys/triscit/fire.gif
new file mode 100644
index 0000000..4034689
Binary files /dev/null and b/content/toys/triscit/fire.gif differ
diff --git a/content/toys/triscit/index.html b/content/toys/triscit/index.html
new file mode 100644
index 0000000..ad1c10e
--- /dev/null
+++ b/content/toys/triscit/index.html
@@ -0,0 +1,95 @@
+---
+title: Triscit
+description: A CPU simulator for cybersecurity education
+type: bare
+---
+
+
+
+
+ Triscit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PC=
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+
+ |
+ |
+
+
+
+ |
+ |
+
+
+
+
+
diff --git a/content/toys/triscit/triscit.mjs b/content/toys/triscit/triscit.mjs
new file mode 100644
index 0000000..ba62625
--- /dev/null
+++ b/content/toys/triscit/triscit.mjs
@@ -0,0 +1,154 @@
+import * as Binutils from "./binutils.mjs"
+
+const FLAG_NEGATIVE = 1 << 0
+const FLAG_HALT = 1 << 1
+const FLAG_ABLAZE = 1 << 2
+
+class Instruction {
+ constructor(name, args, description) {
+ this.Name = name
+ this.Args = args
+ this.Description = description
+ }
+}
+
+const Instructions = [
+ new Instruction("NOOP", [], "Do nothing"),
+ new Instruction("PRNT", ["x"], "Print string at x"),
+ new Instruction("READ", ["x"], "Read input, store in x"),
+ new Instruction("CMPS", ["x", "y"], "Compare string x to string y"),
+ new Instruction("JNEQ", ["x"], "If not equal, set PC to x"),
+ new Instruction("JUMP", ["x"], "Set PC to x"),
+ new Instruction("HALT", [], "Terminate program"),
+ new Instruction("HACF", [], "Halt And Catch Fire (never use this!)"),
+]
+
+function disassemble(program) {
+ let b = program[0]
+ let inst = Instructions[b]
+ if (inst) {
+ let nargs = inst.Args.length
+ let args = []
+ for (let i = 0; i < nargs; i++) {
+ args.push(program[i+1])
+ }
+
+ if (1+nargs <= program.length) {
+ // Only return an opcode if there was enough data for its arguments as well
+ return {length: 1+nargs, name: inst.Name, args: args}
+ }
+ }
+
+ let data = Binutils.CString(program, true)
+ let s = Binutils.Stringify(data)
+ return {length: data.length, name: "DATA", args: [s]}
+}
+
+class CPU {
+ /**
+ * @param {Uint8Array} program Program to load
+ * @param {string} input input string
+ * @param {Number} pc initial program counter
+ * @param {Number} flags initial flags
+ */
+ constructor(program, input, output="", pc=0, flags=0) {
+ this.Program = program
+ this.Input = input
+ this.Output = output
+ this.PC = pc
+ this.Flags = flags
+ }
+
+ Clone() {
+ return new CPU(this.Program, this.Input, this.Output, this.PC, this.Flags)
+ }
+
+ DisassembleProgram() {
+ let ret = []
+ let prog1 = this.Program.slice(0, this.PC)
+ let addr = 0
+ for (let prog of [prog1, this.Program]) {
+ while (addr < prog.length) {
+ let subprog = prog.slice(addr)
+ let inst = disassemble(subprog)
+ ret.push({
+ addr,
+ buf: this.Program.slice(addr, addr+inst.length),
+ name: inst.name,
+ args: inst.args,
+ })
+ addr += inst.length
+ }
+ }
+ return ret
+ }
+
+ Step() {
+ let prog = this.Program.slice(this.PC)
+ let inst = disassemble(prog)
+ let flags = this.Flags
+ this.Flags = 0
+ switch (inst.name) {
+ case "JUMP":
+ this.PC = inst.args[0]
+ return
+ case "JNEQ":
+ if (flags && FLAG_NEGATIVE) {
+ this.PC = inst.args[0]
+ return
+ }
+ break
+ case "READ": {
+ let addr = inst.args[0]
+ let b = new TextEncoder().encode(this.Input + "\0")
+ let newproglen = Math.max(this.Program.length, addr+b.length)
+ let newprog = new Uint8Array(newproglen)
+
+ for (let i = 0; i < addr; i++) {
+ newprog[i] = this.Program[i]
+ }
+ for (let i = 0; i < b.length; i++) {
+ newprog[addr+i] = b[i]
+ }
+ for (let i = addr+b.length; i < this.Program.length; i++) {
+ newprog[i] = this.Program[i]
+ }
+ this.Program = newprog
+ break
+ }
+ case "PRNT": {
+ let addr = inst.args[0]
+ let prog = this.Program.slice(addr)
+ this.Output = Binutils.CString(prog)
+ break
+ }
+ case "CMPS": {
+ let decoder = new TextDecoder()
+ let a = decoder.decode(Binutils.CString(this.Program.slice(inst.args[0])))
+ let b = decoder.decode(Binutils.CString(this.Program.slice(inst.args[1])))
+ if (a != b) {
+ this.Flags = FLAG_NEGATIVE
+ }
+ break
+ }
+ case "HALT":
+ // Don't modify PC
+ this.Flags = FLAG_HALT
+ return
+ case "HACF":
+ this.Flags = FLAG_HALT | FLAG_ABLAZE
+ return
+ case "DATA":
+ // Keep stepping through data one byte at a time trying to execute something
+ inst.length = 1
+ break
+ }
+ this.PC += inst.length
+ }
+}
+
+export {
+ FLAG_NEGATIVE, FLAG_HALT, FLAG_ABLAZE,
+ Instructions,
+ CPU,
+}
\ No newline at end of file
diff --git a/content/toys/triscit/ui.css b/content/toys/triscit/ui.css
new file mode 100644
index 0000000..d9e9be6
--- /dev/null
+++ b/content/toys/triscit/ui.css
@@ -0,0 +1,32 @@
+
+#instructions th.addr {
+ text-align: right;
+ color: brown;
+}
+
+td.name {
+ font-weight: bold;
+}
+
+td.args {
+ color: green;
+}
+
+td.hex {
+ color: #777;
+}
+
+.ablaze {
+ background: url(fire.gif) bottom repeat-x;
+}
+
+@media screen and (min-width: 768px) {
+ #cpu-box {
+ max-height: 95vh;
+ }
+
+ .table-container {
+ max-height: calc(100% - 4em);
+ overflow-y: scroll;
+ }
+}
\ No newline at end of file
diff --git a/content/toys/triscit/ui.mjs b/content/toys/triscit/ui.mjs
new file mode 100644
index 0000000..c9fbf41
--- /dev/null
+++ b/content/toys/triscit/ui.mjs
@@ -0,0 +1,206 @@
+import * as Binutils from "./binutils.mjs"
+import * as Triscit from "./triscit.mjs"
+
+const SamplePrograms = [
+ `05 0d
+61 62 63 64 65 66 67 68 69 6a 00
+02 02
+01 02
+06
+`,
+ `05 31
+61 62 63 64 65 66 67 68 69 6a 00
+70 61 73 73 77 6f 72 64 00
+53 75 70 65 72 20 53 65 63 72 65 74 00
+53 6f 72 72 79 2c 20 77 72 6f 6e 67 2e 00
+02 02
+03 02 0d
+04 3b
+01 16
+06
+01 23
+06
+`
+]
+
+function fill(element, values) {
+ for (let e of element.querySelectorAll("[data-fill]")) {
+ e.textContent = values[e.dataset.fill]
+ }
+}
+
+class UI {
+ constructor(program=[]) {
+ this.program = program
+ this.Init()
+ this.Reset()
+ }
+
+ Init() {
+ for (let e of document.querySelectorAll("[data-control]")) {
+ switch (e.dataset.control) {
+ case "step":
+ e.addEventListener("click", () => this.Step())
+ break
+ case "back":
+ e.addEventListener("click", () => this.Unstep())
+ break
+ case "reset":
+ e.addEventListener("click", () => this.Reset())
+ break
+ case "input":
+ e.addEventListener("input", () => this.SetInput())
+ break
+ case "program":
+ e.addEventListener("input", () => this.SetProgram())
+ }
+ }
+
+ let ih = document.querySelector("#instructions-help")
+ for (let i = 0; i < Triscit.Instructions.length; i++) {
+ let inst = Triscit.Instructions[i]
+ let doc = document.querySelector("template#instruction-help").content.cloneNode(true)
+ let tr = doc.firstElementChild
+ fill(tr, {
+ num: Binutils.Hexlify([i]),
+ args: inst.Args.join(" "),
+ name: inst.Name,
+ description: inst.Description,
+ })
+ ih.appendChild(doc)
+ }
+
+ let fields = new URLSearchParams(window.location.search);
+ let progNumber = fields.get("p") || 0
+ let progElement = document.querySelector('[data-control="program"]')
+ let prog = SamplePrograms[progNumber]
+ if (prog) {
+ progElement.value = SamplePrograms[progNumber]
+ progElement.parentElement.classList.add("is-hidden")
+ }
+
+ this.SetInput()
+ this.SetProgram()
+ }
+
+ Reset() {
+ this.cpu = new Triscit.CPU(this.program, this.input)
+ this.stack = []
+ this.Refresh()
+ }
+
+ Refresh() {
+ let inste = document.querySelector("#instructions")
+ while (inste.firstChild) inste.firstChild.remove()
+ for (let i of this.cpu.DisassembleProgram()) {
+ let doc = document.querySelector("template#instruction").content.cloneNode(true)
+ let tr = doc.firstElementChild
+ fill(tr, {
+ addr: Binutils.Hexlify([i.addr]),
+ name: i.name,
+ hex: Binutils.Hexlify(i.buf),
+ })
+
+ let args = tr.querySelector(".args")
+ for (let a of i.args) {
+ if (! isNaN(a)) {
+ a = Binutils.Hexlify([a])
+ }
+ args.textContent += `${a} `
+ }
+
+ inste.appendChild(tr)
+ if (i.addr == this.cpu.PC) {
+ tr.classList.add("is-selected")
+ tr.scrollIntoView(false)
+ // let top = tr.offsetTop
+ // let parent = tr.parentElement
+
+ // tr.parentElement.scrollTop = top
+ }
+ }
+
+ for (let e of document.querySelectorAll("[data-flag]")) {
+ let mask = 0
+ let className = "is-info"
+ switch (e.dataset.flag) {
+ case "negative":
+ mask = Triscit.FLAG_NEGATIVE
+ break
+ case "halt":
+ mask = Triscit.FLAG_HALT
+ break
+ case "fire":
+ mask = Triscit.FLAG_ABLAZE
+ className = "is-danger"
+ break
+ }
+ if (this.cpu.Flags & mask) {
+ e.classList.add(className)
+ } else {
+ e.classList.remove(className)
+ }
+ }
+
+ for (let cpuBox of document.querySelectorAll("#cpu-box")) {
+ if (this.cpu.Flags & Triscit.FLAG_ABLAZE) {
+ cpuBox.classList.add("ablaze")
+ } else {
+ cpuBox.classList.remove("ablaze")
+ }
+ }
+
+ for (let e of document.querySelectorAll("[data-value]")) {
+ switch (e.dataset.value) {
+ case "pc":
+ e.textContent = Binutils.Hexlify([this.cpu.PC])
+ break
+ case "output":
+ e.textContent = Binutils.Stringify(this.cpu.Output)
+ break
+ }
+ }
+ }
+
+ Step() {
+ if (this.cpu.Flags & Triscit.FLAG_HALT) {
+ return
+ }
+ this.stack.push(this.cpu)
+ this.cpu = this.cpu.Clone()
+ this.cpu.Step()
+ this.Refresh()
+ }
+
+ Unstep() {
+ if (this.stack.length == 0) {
+ return
+ }
+ this.cpu = this.stack.pop()
+ this.Refresh()
+ }
+
+ SetProgram() {
+ let e =document.querySelector('[data-control="program"]')
+ this.program = Binutils.Unhexlify(e.value || "")
+ this.Reset()
+ }
+
+ SetInput() {
+ let e = document.querySelector('[data-control="input"]')
+ let v = e.value || ""
+ this.input = Binutils.Unescape(v)
+ this.Reset()
+ }
+}
+
+function init() {
+ let app = new UI()
+ window.app = app
+}
+
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init)
+} else {
+ init()
+}