[{"data":1,"prerenderedAt":4401},["ShallowReactive",2],{"home-posts":3},[4,1236,1992,2748],{"id":5,"title":6,"body":7,"description":81,"extension":1227,"meta":1228,"navigation":741,"order":110,"path":1230,"seo":1231,"stem":1232,"summary":1233,"visibility":1234,"__hash__":1235},"posts\u002Fposts\u002Freverse-engineering-fortnite-festival.md","Fortnite Festival from the Outside",{"type":8,"value":9,"toc":1207},"minimark",[10,17,20,31,34,37,42,45,48,51,54,58,72,82,85,99,177,214,218,221,286,303,424,427,431,434,437,440,443,447,450,460,470,474,477,480,483,486,556,561,564,567,570,573,577,580,589,592,595,599,602,606,613,814,826,830,833,836,843,850,853,856,978,981,985,988,991,996,1000,1003,1006,1009,1012,1016,1019,1022,1026,1030,1047,1050,1065,1068,1071,1106,1117,1120,1140,1147,1190,1193,1197,1200,1203],[11,12,13],"blockquote",{},[14,15,16],"p",{},"Disclaimer: this is done for educational and research purposes only. I will not be making the source code public.",[14,18,19],{},"This project exists because of one song. Through the Fire and Flames is long, it's brutal, and I am completely mind-blocked on it. When I first played it I could read it fine; I played it so much that my hands memorised a wrong version of it, and now the real notes and the remembered notes fight each other on the way down the highway. I've had that block for over a year now, most of the time the song has existed in Festival, and it's been slowly driving me insane, because the part I fail is deep into a seven-minute song, and Festival gives you no way to loop it. You play the whole thing from the top, every attempt, to reach the section that's beating you.",[14,21,22,23,30],{},"I've played rhythm games for most of my life. osu! around 2016, Robeats religiously through lockdown, then ",[24,25,29],"a",{"href":26,"rel":27},"https:\u002F\u002Fquavergame.com",[28],"nofollow","Quaver",", where I peaked inside the global top 1,700 in 4K. In any of those games this block is a solved problem: convert the chart, mirror it, and your pattern memory doesn't recognise it anymore, so you read it fresh. That trick dies in Festival. Festival is five lanes, and a five-lane mirror isn't the clean swap a four-lane mirror is; the middle stays put and the hand balance changes completely. There's no practice mode, no section looping, no speed control outside of it either, which every rhythm game I've touched in the past decade has. So I built it. Rust is a language I've spent years in, and this was the right kind of problem for it: something I needed, and something I'd learn from.",[14,32,33],{},"For what it's worth, I bounced off Festival at launch: no scroll speed control, the old pre-\"pro\" colours, and lift notes I could not read at all. What pulled me back was a TikTok showing Magical Cure! Love Shot! in the game. It looked fun, I reinstalled, and I full-comboed it within a few days despite never having played five keys before. Keep the lifts in mind; they come back at the end of this post.",[14,35,36],{},"Festival's one API hands you song data and nothing else: no score, no live state, not even where you are in the song. So everything here reads the game from the outside. It reverse-engineers the scoring off pixels recorded from the screen, and drives an overlay that syncs to the live game by pattern-matching my own playing against the chart. None of it touches the game's code or memory.",[38,39,41],"h2",{"id":40},"the-path-follower-came-first","The Path Follower Came First",[14,43,44],{},"Before any of this existed, I'd built myself a much smaller tool. I sometimes follow Star Power paths, the optimiser-computed points in a song where activating overdrive is worth the most, and holding a path in your head mid-song is its own problem. So I made an overlay that fetched the chart's path from CHOpt, watched Festival's log file to see which song had started, and drew the path on screen while I played. It synced by me pressing a key to step through the activations. Manual and dumb, but it worked, and then it sat there.",[14,46,47],{},"The actual project started later, when Through the Fire and Flames finally made me snap: I built the practice client, a whole playable rhythm game pointed at one job. Then I wanted to rewatch my real runs, so replays came next, fed through the same engine. And then I thought, what if I want to see my own Festival scores live while I play, and remembered I'd already built the log-reading thing. Silly. The old path follower got absorbed as the companion, grew a leaderboard of my previous scores, and lost its manual key press for something much better, which is a later section. That's why the architecture looks the way it does: a client that is a whole rhythm game, and a companion that knows what the real game is doing.",[14,49,50],{},"Some of it diverges from Festival on purpose, since this was built around how I wanted to practise rather than as a 1:1 clone, and most of those differences are settings you can tune. A launcher wraps the client, owning everything outside of playing itself and spawning the client straight into a count-in when you hit practice.",[14,52,53],{},"That shape is about reuse. I wanted to watch replays of my real in-game sessions, and the client already had everything a replay needs: hit detection, scoring, a highway, a results screen. Capturing what happens in the real game and feeding it through the client I already trust beats building a second scorer that would drift out of agreement with the first. So everything gets judged by the same engine, whether the notes came from a practice run or from the game itself.",[38,55,57],{"id":56},"architecture","Architecture",[14,59,60,61,65,66,71],{},"The whole thing is a Rust workspace of three crates. ",[62,63,64],"code",{},"core"," is the shared library: chart parsing, AES MIDI decryption, the SQLite database and settings. The launcher is a ",[24,67,70],{"href":68,"rel":69},"https:\u002F\u002Ftauri.app",[28],"Tauri"," 2 app with a React frontend that owns everything outside the highway. The practice client is a third crate the launcher spawns as a separate process.",[73,74,79],"pre",{"className":75,"code":77,"language":78},[76],"language-text","crates\u002F\n├── core\u002F       # shared library: chart parse, AES decrypt, SQLite DB, settings\n├── launcher\u002F   # Tauri 2 + React UI: catalogue, analysis, sections, replays, overlay\n└── practice\u002F   # macroquad + egui gameplay client, spawned per session\n","text",[62,80,77],{"__ignoreMap":81},"",[14,83,84],{},"The two processes deliberately share almost nothing: a SQLite database in WAL mode, an argv contract, and an exit code. The launcher owns the catalogue, section management and analysis. The client keeps its verified timing and scoring stack; the launcher never links against it, so neither can quietly break the other.",[14,86,87,88,93,94,98],{},"Inside, the client is a game state machine where ",[89,90,92],"speak",{"as":91},"macro-quad","macroquad"," handles 3D rendering (the note highway) and ",[89,95,97],{"as":96},"ee gooey","egui"," handles menus and HUD as an immediate-mode GUI overlay; each frame polls input, advances game logic and renders. It does the hit detection, the multi-stem audio, the scoring and the highway, everything a rhythm game needs. What it dropped when it moved under the launcher is song browsing and loading, so the spawned client goes straight into gameplay instead of starting at a browser. Its state machine is short:",[73,100,104],{"className":101,"code":102,"language":103,"meta":81,"style":81},"language-rust shiki shiki-themes dromzeh","enum AppState {\n    Practicing(PracticingState),    \u002F\u002F live gameplay; a whole song is just a section\n    Results(ResultsState),          \u002F\u002F final score, accuracy, timing histogram\n    WatchingReplay(WatchingReplayState),\n}\n","rust",[62,105,106,122,141,157,171],{"__ignoreMap":81},[107,108,111,115,118],"span",{"class":109,"line":110},"line",1,[107,112,114],{"class":113},"sH9Dd","enum",[107,116,117],{"class":113}," AppState",[107,119,121],{"class":120},"svQrX"," {\n",[107,123,125,128,131,134,137],{"class":109,"line":124},2,[107,126,127],{"class":113},"    Practicing",[107,129,130],{"class":120},"(",[107,132,133],{"class":113},"PracticingState",[107,135,136],{"class":120},"),",[107,138,140],{"class":139},"sosU6","    \u002F\u002F live gameplay; a whole song is just a section\n",[107,142,144,147,149,152,154],{"class":109,"line":143},3,[107,145,146],{"class":113},"    Results",[107,148,130],{"class":120},[107,150,151],{"class":113},"ResultsState",[107,153,136],{"class":120},[107,155,156],{"class":139},"          \u002F\u002F final score, accuracy, timing histogram\n",[107,158,160,163,165,168],{"class":109,"line":159},4,[107,161,162],{"class":113},"    WatchingReplay",[107,164,130],{"class":120},[107,166,167],{"class":113},"WatchingReplayState",[107,169,170],{"class":120},"),\n",[107,172,174],{"class":109,"line":173},5,[107,175,176],{"class":120},"}\n",[14,178,179,180,183,184,183,187,190,191,194,195,198,199,202,203,206,207,213],{},"The old ",[62,181,182],{},"Loading",", ",[62,185,186],{},"SongSelect",[62,188,189],{},"Playing"," and ",[62,192,193],{},"Paused"," states are gone. Live gameplay always enters ",[62,196,197],{},"Practicing",", where a full song is simply a section that spans the whole chart; from there it moves to ",[62,200,201],{},"Results",", or, when the launcher spawns it with ",[62,204,205],{},"--replay",", into ",[89,208,210],{"as":209},"Watching Replay",[62,211,212],{},"WatchingReplay"," where recorded input events play back against the original chart.",[38,215,217],{"id":216},"parsing-midi-charts","Parsing MIDI Charts",[14,219,220],{},"Each decrypted MIDI file contains instrument-specific tracks at four difficulty tiers where the MIDI note numbers map to fret positions:",[222,223,224,240],"table",{},[225,226,227],"thead",{},[228,229,230,234,237],"tr",{},[231,232,233],"th",{},"Difficulty",[231,235,236],{},"MIDI Note Range",[231,238,239],{},"Frets",[241,242,243,255,265,275],"tbody",{},[228,244,245,249,252],{},[246,247,248],"td",{},"Easy",[246,250,251],{},"60-63",[246,253,254],{},"4",[228,256,257,260,263],{},[246,258,259],{},"Medium",[246,261,262],{},"72-75",[246,264,254],{},[228,266,267,270,273],{},[246,268,269],{},"Hard",[246,271,272],{},"84-87",[246,274,254],{},[228,276,277,280,283],{},[246,278,279],{},"Expert",[246,281,282],{},"96-100",[246,284,285],{},"5",[14,287,288,289,295,296,302],{},"Special markers encode gameplay mechanics beyond the basic fret notes: MIDI note 116 defines overdrive phrase boundaries where note-on starts a phrase and note-off ends it, base+6 through base+10 are lift notes and notes occurring on the same MIDI tick are detected as chords. Each parsed note is stored as a struct carrying its tick position, absolute time in milliseconds, fret number (0-4), sustain duration in ticks, milliseconds and beats, along with a ",[89,290,292],{"as":291},"Note Flags",[62,293,294],{},"NoteFlags"," bitfield using the ",[89,297,299],{"as":298},"bit-flags",[62,300,301],{},"bitflags"," crate for efficient per-note metadata:",[73,304,306],{"className":101,"code":305,"language":103,"meta":81,"style":81},"pub struct Note {\n    tick: u32,\n    time_ms: f64,\n    fret: u8,              \u002F\u002F 0-4 (green through orange)\n    sustain_ticks: u32,\n    sustain_ms: f64,\n    sustain_beats: f32,    \u002F\u002F for display scaling\n    flags: NoteFlags,      \u002F\u002F TAP | OVERDRIVE | LIFT | CHORD\n}\n",[62,307,308,321,336,348,364,375,387,403,419],{"__ignoreMap":81},[107,309,310,313,316,319],{"class":109,"line":110},[107,311,312],{"class":113},"pub",[107,314,315],{"class":113}," struct",[107,317,318],{"class":113}," Note",[107,320,121],{"class":120},[107,322,323,327,330,333],{"class":109,"line":124},[107,324,326],{"class":325},"s_Mgl","    tick",[107,328,329],{"class":120},":",[107,331,332],{"class":113}," u32",[107,334,335],{"class":120},",\n",[107,337,338,341,343,346],{"class":109,"line":143},[107,339,340],{"class":325},"    time_ms",[107,342,329],{"class":120},[107,344,345],{"class":113}," f64",[107,347,335],{"class":120},[107,349,350,353,355,358,361],{"class":109,"line":159},[107,351,352],{"class":325},"    fret",[107,354,329],{"class":120},[107,356,357],{"class":113}," u8",[107,359,360],{"class":120},",",[107,362,363],{"class":139},"              \u002F\u002F 0-4 (green through orange)\n",[107,365,366,369,371,373],{"class":109,"line":173},[107,367,368],{"class":325},"    sustain_ticks",[107,370,329],{"class":120},[107,372,332],{"class":113},[107,374,335],{"class":120},[107,376,378,381,383,385],{"class":109,"line":377},6,[107,379,380],{"class":325},"    sustain_ms",[107,382,329],{"class":120},[107,384,345],{"class":113},[107,386,335],{"class":120},[107,388,390,393,395,398,400],{"class":109,"line":389},7,[107,391,392],{"class":325},"    sustain_beats",[107,394,329],{"class":120},[107,396,397],{"class":113}," f32",[107,399,360],{"class":120},[107,401,402],{"class":139},"    \u002F\u002F for display scaling\n",[107,404,406,409,411,414,416],{"class":109,"line":405},8,[107,407,408],{"class":325},"    flags",[107,410,329],{"class":120},[107,412,413],{"class":113}," NoteFlags",[107,415,360],{"class":120},[107,417,418],{"class":139},"      \u002F\u002F TAP | OVERDRIVE | LIFT | CHORD\n",[107,420,422],{"class":109,"line":421},9,[107,423,176],{"class":120},[14,425,426],{},"The same MIDI carries a SECTION track of named landmarks (intro, verse, chorus and so on); the launcher reads those as the anchors you build practice sections around.",[38,428,430],{"id":429},"the-clock","The Clock",[14,432,433],{},"The client's audio position originally came straight from the audio engine, and that was the wrong source of truth. The engine reports position in ~45ms steps (one audio buffer at a time), and the first release smoothed over the steps with interpolate-then-snap logic that traded stepping for wobble. In a game where the perfect window is ±25ms, a clock that quantises at 45ms is disqualified before it starts.",[14,435,436],{},"The fix inverts the relationship. A wall-anchored timer is the position: anchor the moment playback starts, advance on the monotonic clock, re-anchor on every seek, pause and speed change. The engine's reported position is only consulted as a drift check, through one small pure function that takes the predicted position and the raw report and returns a correction only when they disagree by more than 150ms.",[14,438,439],{},"When I finally probed the engine headless for a full song, it tracked the wall clock within ±7ms over 412 seconds with all five stems in lockstep, which means the safety net has likely never fired in anger. The stepping was the problem, not the engine's accuracy.",[14,441,442],{},"At practice speeds the anchor advances at the playback rate, so slowing a section to 0.5x slows time itself: the highway, the count-in and the hit windows all stay glued to the audio for free, and because hit windows are defined in chart time, half speed also means double the real-time margin for your hands, which is what you want a practice speed for.",[38,444,446],{"id":445},"practice-mode","Practice Mode",[14,448,449],{},"Sections are created against a picture of the song rather than typed as timestamps. The launcher renders the whole chart as a notes-per-second heat strip, resampling the cached 500ms NPS buckets into a fixed number of columns with area weighting so long songs don't alias, and draws the chart's own section names underneath as landmarks. You drag across the strip and the create form prefills with the range; clicking a section label snaps to that section exactly and takes its name.",[14,451,452,453,190,456,459],{},"Saved sections live in the shared database, and each row has its own play button. Launching one forwards the range on the command line as ",[62,454,455],{},"--range-start-ms",[62,457,458],{},"--range-end-ms",", which the client turns into a synthetic section through the same code path a chart section uses, so the count-in, looping and auto-restart behave identically whether the section came from the chart or from a drag.",[14,461,462,463,469],{},"On the client side, the practice engine in ",[89,464,466],{"as":465},"game, practice dot r-s",[62,467,468],{},"game\u002Fpractice.rs"," runs the loop. A configurable count-in plays the audio with no notes for a number of beats (default 4) before the section starts, giving you time to orient yourself before notes appear; auto-restart watches the miss count during a section and loops back to the start, count-in and all, once it crosses the threshold. Speed presets scale the same wall-anchored clock, so slowing a section slows the audio, the highway, the count-in and the hit windows together and nothing drifts out of line at any rate.",[38,471,473],{"id":472},"festivals-scoring-reverse-engineered","Festival's Scoring, Reverse-Engineered",[14,475,476],{},"The engine agrees with the game to within 25 points on a 335,000-point run. Every constant in it traces to a frame of recorded gameplay. But the reason any of this exists is that I thought my own client was broken.",[14,478,479],{},"It started with Paradise City. Playing it in the client felt off-time compared to Festival, late in a way I couldn't pin down, and it was driving me mental. I recorded myself playing it in Fortnite, recorded the same song in the client, and compared the two videos frame by frame expecting to find drift. There wasn't any. The notes were exactly on time in both. The real cause was the stem mix: the client was boosting the lead stem, the instrument you're playing, at 1.5x to copy Festival's you-hear-yourself behaviour, and it was loud enough to warp my sense of where the beat was. The client was innocent, my ears were the bug.",[14,481,482],{},"But that comparison rig, two recordings and a frame counter, turned out to be worth more than the bug it acquitted. If I could line up video against the client's scoring, I could line up video against Festival's scoring, and Festival publishes every number that matters, sixty times a second, in the corner of the screen.",[14,484,485],{},"The rules as they actually are:",[222,487,488,498],{},[225,489,490],{},[228,491,492,495],{},[231,493,494],{},"Rule",[231,496,497],{},"Value",[241,499,500,508,516,524,532,540,548],{},[228,501,502,505],{},[246,503,504],{},"Base note",[246,506,507],{},"30 points",[228,509,510,513],{},[246,511,512],{},"Perfect (±25ms)",[246,514,515],{},"×1.2 → 36",[228,517,518,521],{},[246,519,520],{},"Good (±100ms)",[246,522,523],{},"×1.0 → 30",[228,525,526,529],{},[246,527,528],{},"Combo",[246,530,531],{},"Delayed: steps every ten notes, the eleventh is the first 2x",[228,533,534,537],{},[246,535,536],{},"Combo cap",[246,538,539],{},"4x lead and drums · 6x bass and vocals",[228,541,542,545],{},[246,543,544],{},"Sustain",[246,546,547],{},"12 points per beat × combo; under one beat pays nothing",[228,549,550,553],{},[246,551,552],{},"Overdrive",[246,554,555],{},"×2 on whatever is true; the window extends when another phrase completes inside it",[557,558,560],"h3",{"id":559},"reading-the-game-off-the-screen","Reading the Game Off the Screen",[14,562,563],{},"One run recorded at 60fps, the score counter cropped from every frame, binarised, and OCR'd: 18,671 frames. The counter snaps instantly on every note, and a cumulative, monotonic counter sampled at 60Hz isn't a picture of the score, it's a ledger of every note's exact value.",[14,565,566],{},"On a full combo with no overdrive the decode is total. Combo equals note index, so the multiplier schedule is fully determined, and every increment factors uniquely as a perfect or a good at a known multiplier, so you get a verdict per note out of the pixels.",[14,568,569],{},"Everything surprising came out of that one recording. The results screen number is a count-up animation, which matters if a screenshot catches it mid-roll and the game appears to disagree with itself by six and a half thousand points. Sustains under a beat pay nothing: an entire section of quarter-beat trails paid a flat +144 per note, no trickle. Qualifying sustains are multiplied: one isolated 1.5-beat hold, sitting before an eleven-second break with nothing else to contaminate the reading, paid exactly 72 points, which is 18 for the sustain times the 4x multiplier that supposedly never applies.",[14,571,572],{},"A second recording with overdrive settled the window-extension rule the same way, from the lengths of the doubled regions alone. When the engine's total first landed within spitting distance of the game's, my honest reaction at the desk was: what have I made? Because at that point it wasn't a practice tool anymore. I'd made an entire rhythm game.",[557,574,576],{"id":575},"the-unreachable-last-points","The Unreachable Last Points",[14,578,579],{},"The last few hundred points are not reachable, and the reason is more interesting than a bug. The design window for a perfect is ±25ms, but the game judges a keypress on its own engine tick, Unreal sampling input on its schedule, while the capture timestamps the same keypress on a wall clock. Measured from the outside, the effective boundary between the two clocks landed at 27.0ms in one session and 25.5ms in another, with mixed early\u002Flate signs that rule out a constant offset. A dense chart stacks enough notes right on that boundary that the wobble is worth a few hundred points on any fixed window.",[581,582,584],"figure",{"caption":583},"The perfect-window boundary as measured from outside the game. The design window is ±25 ms, the two sessions landed at 25.5 ms and 27.0 ms, and the engine adopts the midpoint of the two measurements.",[585,586],"plot-chart",{":data":587,":max":588},"[{\"label\":\"design window\",\"value\":25,\"display\":\"25 ms\"},{\"label\":\"session B\",\"value\":25.5,\"display\":\"25.5 ms\"},{\"label\":\"session A\",\"value\":27,\"display\":\"27.0 ms\"},{\"label\":\"adopted\",\"value\":26.25,\"display\":\"26.25 ms\",\"em\":true}]","30",[14,590,591],{},"The engine uses the midpoint, 26.25ms, and stops there, because the residual is not an error in either scorer. It is two clocks watching one finger and disagreeing about the edge.",[14,593,594],{},"The verification loop is the same every time: play a session in the real game, capture every input with timestamps, score it through the one engine, and compare against what the game displayed, note by note when a recording exists.",[38,596,598],{"id":597},"linking-into-fortnite","Linking Into Fortnite",[14,600,601],{},"The companion side of the launcher needs to know what's happening inside a running song. Festival's API only lists songs; it says nothing about a live one. It turns out Fortnite tells you anyway, if you listen in the right places.",[557,603,605],{"id":604},"tailing-the-log","Tailing the Log",[14,607,608,609,612],{},"The first place is the log file. Festival's internal codename is Pilgrim, and the game writes ",[62,610,611],{},"LogPilgrim*"," lines for every meaningful transition. Tail the log, run each line through a handful of regexes, and you get a clean event stream:",[73,614,616],{"className":101,"code":615,"language":103,"meta":81,"style":81},"pub enum LogEvent {\n    \u002F\u002F\u002F \"Song data set. N gems found for …Difficulty… …TrackType…\" — the song\n    \u002F\u002F\u002F started on this instrument and difficulty.\n    SongStarted { instrument: Instrument, difficulty: String },\n    \u002F\u002F\u002F \"local client finished loading song \u003Cid>\" — the song id is now known.\n    SongLoaded { song_id: String },\n    \u002F\u002F\u002F Quickplay reached the intro state; this is what arms the matcher.\n    SongIntro,\n    EnteredBackstage,\n    LeftBackstage,\n    StoppingSong,\n    \u002F\u002F ...\n}\n\nstatic DIFFICULTY_REGEX: Lazy\u003CRegex> = Lazy::new(|| {\n    Regex::new(r\"LogPilgrimGameEvaluator: \\[....\\] : Song data set\\. [0-9]* gems found for \").unwrap()\n});\n",[62,617,618,630,635,640,669,674,690,695,702,709,717,725,731,736,743,783,808],{"__ignoreMap":81},[107,619,620,622,625,628],{"class":109,"line":110},[107,621,312],{"class":113},[107,623,624],{"class":113}," enum",[107,626,627],{"class":113}," LogEvent",[107,629,121],{"class":120},[107,631,632],{"class":109,"line":124},[107,633,634],{"class":139},"    \u002F\u002F\u002F \"Song data set. N gems found for …Difficulty… …TrackType…\" — the song\n",[107,636,637],{"class":109,"line":143},[107,638,639],{"class":139},"    \u002F\u002F\u002F started on this instrument and difficulty.\n",[107,641,642,645,648,651,653,656,658,661,663,666],{"class":109,"line":159},[107,643,644],{"class":113},"    SongStarted",[107,646,647],{"class":120}," {",[107,649,650],{"class":325}," instrument",[107,652,329],{"class":120},[107,654,655],{"class":113}," Instrument",[107,657,360],{"class":120},[107,659,660],{"class":325}," difficulty",[107,662,329],{"class":120},[107,664,665],{"class":113}," String",[107,667,668],{"class":120}," },\n",[107,670,671],{"class":109,"line":173},[107,672,673],{"class":139},"    \u002F\u002F\u002F \"local client finished loading song \u003Cid>\" — the song id is now known.\n",[107,675,676,679,681,684,686,688],{"class":109,"line":377},[107,677,678],{"class":113},"    SongLoaded",[107,680,647],{"class":120},[107,682,683],{"class":325}," song_id",[107,685,329],{"class":120},[107,687,665],{"class":113},[107,689,668],{"class":120},[107,691,692],{"class":109,"line":389},[107,693,694],{"class":139},"    \u002F\u002F\u002F Quickplay reached the intro state; this is what arms the matcher.\n",[107,696,697,700],{"class":109,"line":405},[107,698,699],{"class":113},"    SongIntro",[107,701,335],{"class":120},[107,703,704,707],{"class":109,"line":421},[107,705,706],{"class":113},"    EnteredBackstage",[107,708,335],{"class":120},[107,710,712,715],{"class":109,"line":711},10,[107,713,714],{"class":113},"    LeftBackstage",[107,716,335],{"class":120},[107,718,720,723],{"class":109,"line":719},11,[107,721,722],{"class":113},"    StoppingSong",[107,724,335],{"class":120},[107,726,728],{"class":109,"line":727},12,[107,729,730],{"class":139},"    \u002F\u002F ...\n",[107,732,734],{"class":109,"line":733},13,[107,735,176],{"class":120},[107,737,739],{"class":109,"line":738},14,[107,740,742],{"emptyLinePlaceholder":741},true,"\n",[107,744,746,749,753,755,758,761,764,767,770,772,775,778,781],{"class":109,"line":745},15,[107,747,748],{"class":113},"static",[107,750,752],{"class":751},"sneuw"," DIFFICULTY_REGEX",[107,754,329],{"class":120},[107,756,757],{"class":113}," Lazy",[107,759,760],{"class":120},"\u003C",[107,762,763],{"class":113},"Regex",[107,765,766],{"class":120},">",[107,768,769],{"class":120}," =",[107,771,757],{"class":113},[107,773,774],{"class":120},"::",[107,776,777],{"class":113},"new",[107,779,780],{"class":120},"(||",[107,782,121],{"class":120},[107,784,786,789,791,793,795,799,802,805],{"class":109,"line":785},16,[107,787,788],{"class":325},"    Regex",[107,790,774],{"class":120},[107,792,777],{"class":113},[107,794,130],{"class":120},[107,796,798],{"class":797},"sRQ_c","r\"LogPilgrimGameEvaluator: \\[....\\] : Song data set\\. [0-9]* gems found for \"",[107,800,801],{"class":120},").",[107,803,804],{"class":113},"unwrap",[107,806,807],{"class":120},"()\n",[107,809,811],{"class":109,"line":810},17,[107,812,813],{"class":120},"});\n",[14,815,816,817,821,822,825],{},"That stream tells you ",[818,819,820],"em",{},"which"," song started and on which instrument, but not ",[818,823,824],{},"where"," the player is inside it. The song-started line is honest about the fact and useless as a clock: it fires around the start, not at it, off by a second or so, and in multiplayer the gap varies with loading and connection wait times. A second doesn't sound like much until you remember a dense chart runs 16 notes a second. So the log event arms the matcher and nothing more.",[557,827,829],{"id":828},"the-player-is-the-clock","The Player Is the Clock",[14,831,832],{},"I sat on this problem for ages and pivoted more times than I want to admit. OCR off the screen was the obvious idea and it's disqualified by its own delay; the log is off by a second; at one point I was ready to just track by measure and eat being a little off. Locking onto the first note the player hits sounds right and dies to reality, because a stray tap while waiting for the song is enough to anchor the whole overlay to a lie. The answer I landed on is that the player is the clock, but plural: not one press, a pattern of them.",[14,834,835],{},"What actually cracked it was noticing I'd already built the answer. The practice client existed before any of this linked into Fortnite, and once replay mode existed I had an engine whose whole job is taking a stream of timestamped presses and running it against a chart. Hold on, I thought: a live Fortnite session is that same stream, arriving in real time. So the companion just runs replay mode in the background on my own keys, and the only thing it still needs from Fortnite is where zero is. Listen for the first couple of notes, line them up against the chart, and the rest follows.",[14,837,838,839,842],{},"Fret presses are captured with ",[62,840,841],{},"GetAsyncKeyState"," polling on a dedicated thread, timestamped on the monotonic clock into a small ring buffer.",[14,844,845,846,849],{},"The choice of polling over a keyboard hook is deliberate and it's about latency: a low-level hook (",[62,847,848],{},"WH_KEYBOARD_LL",") inserts itself into the input delivery chain, so every keystroke in the system synchronously waits on your callback before the game sees it, and a slow moment in your process becomes input lag in theirs. Polling never touches that chain. It reads the key state table Windows already maintains, the game's input path stays exactly as long as it was, and the worst the companion can do to your timing is nothing at all. The same poll reads the fret bindings from the shared settings, so it always watches the keys you actually play on.",[14,851,852],{},"When the log shows the song loading in, before the first note, the matcher arms and listens.",[14,854,855],{},"The chart's opening is reduced to onset groups: clusters of note times with the lanes they occupy. Incoming presses get clustered the same way, and the matcher tries to align the two sequences. It takes a window of the first few onset groups, derives the offset that would map a candidate press cluster onto the first onset, and checks whether the following clusters land within tolerance of the following onsets on compatible lanes. If the player flubbed a note or tapped a fret while waiting, a skip parameter slides the window forward so a pre-tap or a missed opening note can't poison the lock. Three distinct groups aligning within 120ms each (half an NPS bucket) commits the match, and the offset becomes the anchor: song position is just the monotonic clock minus that offset from then on.",[73,857,859],{"className":101,"code":858,"language":103,"meta":81,"style":81},"\u002F\u002F\u002F Per-onset residual tolerance accepted for the opening alignment.\nconst MATCH_TOL_MS: f64 = 120.0;\n\u002F\u002F\u002F Distinct onset groups that must align before we commit a lock.\nconst MATCH_GROUPS: usize = 3;\n\npub enum Lifecycle {\n    Disarmed,\n    \u002F\u002F\u002F Capturing opening presses, no anchor yet (\"syncing...\").\n    Listening,\n    \u002F\u002F\u002F Pattern-locked; position runs forward on the wall clock.\n    Locked,\n    \u002F\u002F\u002F No clean lock; wall-clock estimate from song start.\n    Degraded,\n}\n",[62,860,861,866,892,897,916,920,931,938,943,950,955,962,967,974],{"__ignoreMap":81},[107,862,863],{"class":109,"line":110},[107,864,865],{"class":139},"\u002F\u002F\u002F Per-onset residual tolerance accepted for the opening alignment.\n",[107,867,868,871,874,876,878,880,883,886,889],{"class":109,"line":124},[107,869,870],{"class":113},"const",[107,872,873],{"class":751}," MATCH_TOL_MS",[107,875,329],{"class":120},[107,877,345],{"class":113},[107,879,769],{"class":120},[107,881,882],{"class":751}," 120",[107,884,885],{"class":120},".",[107,887,888],{"class":751},"0",[107,890,891],{"class":120},";\n",[107,893,894],{"class":109,"line":143},[107,895,896],{"class":139},"\u002F\u002F\u002F Distinct onset groups that must align before we commit a lock.\n",[107,898,899,901,904,906,909,911,914],{"class":109,"line":159},[107,900,870],{"class":113},[107,902,903],{"class":751}," MATCH_GROUPS",[107,905,329],{"class":120},[107,907,908],{"class":113}," usize",[107,910,769],{"class":120},[107,912,913],{"class":751}," 3",[107,915,891],{"class":120},[107,917,918],{"class":109,"line":173},[107,919,742],{"emptyLinePlaceholder":741},[107,921,922,924,926,929],{"class":109,"line":377},[107,923,312],{"class":113},[107,925,624],{"class":113},[107,927,928],{"class":113}," Lifecycle",[107,930,121],{"class":120},[107,932,933,936],{"class":109,"line":389},[107,934,935],{"class":113},"    Disarmed",[107,937,335],{"class":120},[107,939,940],{"class":109,"line":405},[107,941,942],{"class":139},"    \u002F\u002F\u002F Capturing opening presses, no anchor yet (\"syncing...\").\n",[107,944,945,948],{"class":109,"line":421},[107,946,947],{"class":113},"    Listening",[107,949,335],{"class":120},[107,951,952],{"class":109,"line":711},[107,953,954],{"class":139},"    \u002F\u002F\u002F Pattern-locked; position runs forward on the wall clock.\n",[107,956,957,960],{"class":109,"line":719},[107,958,959],{"class":113},"    Locked",[107,961,335],{"class":120},[107,963,964],{"class":109,"line":727},[107,965,966],{"class":139},"    \u002F\u002F\u002F No clean lock; wall-clock estimate from song start.\n",[107,968,969,972],{"class":109,"line":733},[107,970,971],{"class":113},"    Degraded",[107,973,335],{"class":120},[107,975,976],{"class":109,"line":738},[107,977,176],{"class":120},[14,979,980],{},"Once locked, song position zero is pinned to a monotonic clock value and everything runs forward from there and never needs another press. If ten seconds pass without a clean lock (the player flubbed the intro), it degrades to a rough wall-clock estimate instead of showing nothing. You sync the overlay to the game by playing the game; the opening notes are the handshake.",[557,982,984],{"id":983},"drawing-the-overlay","Drawing the Overlay",[14,986,987],{},"The overlay itself is a native Win32 layered window (per-pixel alpha, clickthrough, always-on-top) drawn with GDI rather than a webview, because it has to coexist with a fullscreen game without stealing input or a compositor's worth of resources. It sits in the corner and updates in real time off nothing but the lock.",[14,989,990],{},"What it draws is the chart as a difficulty graph: the whole song's notes-per-second curve with a sweep line riding across it at the locked position, so the dense stretch is visible before it arrives, and a live score checked against my previous scores on the chart so a run has something to beat.",[992,993],"video-clip",{"caption":994,"src":995},"Beyond the Flame (Expert Lead) in-game, the overlay pinned bottom right. The sweep line rides the difficulty graph at my position, with a leaderboard of my previous scores above it. None of it reads the game: it is running entirely off the pattern-match lock from the opening notes. And the clacking you can hear is my keyboard through the mic, the closest this build gets to hitsounds.","https:\u002F\u002Fx.dromzeh.dev\u002FShareX\u002F2026\u002F07\u002Ffnf-ingame-overlay.mp4",[557,997,999],{"id":998},"live-overdrive-path-feedback","Live Overdrive-Path Feedback",[14,1001,1002],{},"The overlay's other half is the descendant of that first path follower, minus the part where I press a key to advance it. A path box sits on top of the highway, pinned wherever I want it, listing the overdrive activations CHOpt worked out for the chart, with the one you are on marked. Making that mark track your run on its own is the hard bit, because Fortnite never tells the overlay where you are in the path any more than it tells it the time. It infers that too. A scoring pass runs over the run so far and counts the double-score windows you have actually opened, not the ones you whiffed, and that count is the index into the path list, so the next-activation marker only moves when the game moves.",[14,1004,1005],{},"Overdrive activation is detected the same way position is: your own key. Hitting the bound overdrive key records an activation into the capture and steps the highlight forward. Whether that press did anything is decided afterward, by scoring it against the star power you had banked at that moment. Fire with an empty bank and you open no window, and the box flashes for a couple of seconds to tell you the activation was wasted. Underneath, a status line reads the windows you have opened against how many the path expects by now, so you can see at a glance whether you are on plan, ahead of it, or behind. There is no per-activation callout for a mistimed phrase; a skipped or early activation just shows up as running behind, and as the live score slipping under the leaderboard mark.",[14,1007,1008],{},"The count can drift. It rides on the lock holding, the clock staying aligned to the game, the scorer matching every activation to a window, and a 5ms input poll that can miss a fast double-tap, and any of those can leave it a window ahead or behind where you really are in the printed path. So two keys nudge the highlight by one in either direction, and a third freezes the auto-tracking once it is right, so a good manual correction is not overwritten on the next pass.",[14,1010,1011],{},"The scoring pass runs slow on purpose: it re-reads the run about once a second, so the leaderboard score trails Fortnite's instant counter by up to a beat. Recomputing the whole run continuously would put real work right next to the input capture, and the companion's one rule, the same reason it polls the keyboard instead of hooking it, is to never cost the game a millisecond.",[38,1013,1015],{"id":1014},"replay-analysis","Replay Analysis",[14,1017,1018],{},"A replay is a real run fed back through the same engine, so the client can show you the run the way the game never does. The replay viewer has an analysis mode. Turn it on and every note wears its timing from the moment it spawns rather than the moment you would have hit it: gold when the press landed inside the perfect window, blue when it was early, pink when it was late, a dark X where the note was missed. A whole run's timing reads at a glance, so a section that is quietly rushing shows up as a wall of blue long before it ever costs you a note. None of that is numbers on screen; the millisecond spread shows in a corner histogram, and the full per-note breakdown lives in the headless timing tools.",[14,1020,1021],{},"The clip below is the same Beyond the Flame run, played back in the client with the original Fortnite capture pinned in the corner. Both are driven by one chart time, so the client's highway and the game's own footage stay locked frame for frame. That is the end-to-end check: if the replay, the timing and the scoring agree with what actually happened, they agree with the video too.",[992,1023],{"caption":1024,"src":1025},"The same Beyond the Flame run in the client's replay viewer, the original Fortnite capture in the corner. One chart time drives both, so the highway and the real footage stay locked frame for frame. Analysis mode, toggled through the clip, tints each note by how it was timed: gold inside the perfect window, blue early, pink late, a dark X for a miss.","https:\u002F\u002Fx.dromzeh.dev\u002FShareX\u002F2026\u002F07\u002Freplay-btf-fnclient-analysis.mp4",[38,1027,1029],{"id":1028},"patching-chopt-for-lift-notes","Patching CHOpt for Lift Notes",[14,1031,1032,1033,1038,1039,1042,1043,1046],{},"Path images come from ",[24,1034,1037],{"href":1035,"rel":1036},"https:\u002F\u002Fgithub.com\u002FGenericMadScientist\u002FCHOpt",[28],"CHOpt",", a Star Power path optimiser made for Rock Band and the like with Fortnite Festival support. The launcher's relationship with it is stranger than it should be. It ships two builds side by side: stock CHOpt, and a fork with Festival's scoring model patched in. The counterintuitive part is which binary does what. The fork runs with ",[62,1040,1041],{},"--engine rb"," and produces the path text; stock runs with ",[62,1044,1045],{},"--engine fnf"," and produces the score and the image. It reads exactly backwards, and it's the empirically proven recipe: the fork's scoring model makes the right activation decisions but doesn't total correctly, stock totals correctly but paths worse, and no single binary gets both right. Every song runs through both, and the launcher stitches the results into one answer.",[14,1048,1049],{},"The image half of that arrangement is where lifts come in, and this is the part I mentioned keeping in mind: the mechanic I couldn't read when I bounced off Festival at launch is the one I ended up teaching the tooling to draw. Stock CHOpt supports Festival but didn't know lift notes existed: its parser's note range stopped short of the lift markers, so they were dropped before the renderer ever saw them. In the MIDI, lifts live at fixed offsets above each difficulty's base note (keys 102-106 on Expert, one per lane), with the wrinkle that the green-lane lift shares a key with a force marker, and in Festival charts the lift reading wins.",[14,1051,1052,1053,1058,1059,1064],{},"The patch is three small layers on ",[24,1054,1057],{"href":1055,"rel":1056},"https:\u002F\u002Fgithub.com\u002Fdromzeh\u002FCHOpt\u002Ftree\u002Flift-triangles",[28],"a branch",", and the first layer isn't in CHOpt at all. Chart parsing lives in ",[24,1060,1063],{"href":1061,"rel":1062},"https:\u002F\u002Fgithub.com\u002FGenericMadScientist\u002FSightRead",[28],"SightRead",", a separate library CHOpt pins as a submodule: SightRead reads the MIDI into the note stream CHOpt paths and draws over, so a note class it never emits is one CHOpt can never see. The lift flag has to start there: a new bit on the note-flags enum, a collection pass that gathers lift note-ons per difficulty, and an application pass that marks the note sharing each lift's tick and lane. All of it runs only for the Fortnite track type, and it's purely additive; it marks existing notes and never creates or moves one, so the optimiser's input is untouched and paths and scores stay byte-identical.",[14,1066,1067],{},"Rather than fork the parser library for a flag and two passes, the branch vendors it: the submodule becomes plain committed files with the patch applied, which keeps the whole change self-contained in one branch that CI can build without any upstream involvement.",[14,1069,1070],{},"In SightRead, the new flag sits alongside the existing note flags:",[73,1072,1076],{"className":1073,"code":1074,"language":1075,"meta":81,"style":81},"language-cpp shiki shiki-themes dromzeh","enum NoteFlags : std::uint32_t {\n    \u002F\u002F ...\n    FLAGS_FORCE_STRUM = 1U \u003C\u003C 7,\n    FLAGS_LIFT        = 1U \u003C\u003C 8, \u002F\u002F [!code ++]\n    \u002F\u002F ...\n};\n","cpp",[62,1077,1078,1083,1087,1092,1097,1101],{"__ignoreMap":81},[107,1079,1080],{"class":109,"line":110},[107,1081,1082],{},"enum NoteFlags : std::uint32_t {\n",[107,1084,1085],{"class":109,"line":124},[107,1086,730],{},[107,1088,1089],{"class":109,"line":143},[107,1090,1091],{},"    FLAGS_FORCE_STRUM = 1U \u003C\u003C 7,\n",[107,1093,1094],{"class":109,"line":159},[107,1095,1096],{},"    FLAGS_LIFT        = 1U \u003C\u003C 8, \u002F\u002F [!code ++]\n",[107,1098,1099],{"class":109,"line":173},[107,1100,730],{},[107,1102,1103],{"class":109,"line":377},[107,1104,1105],{},"};\n",[14,1107,1108,1109,1112,1113,1116],{},"The collection pass walks the MIDI track once and records where lifts occur. Each difficulty's five lift keys are ",[62,1110,1111],{},"base + 6"," through ",[62,1114,1115],{},"base + 10"," in green-to-orange order (Expert base 96, so keys 102 to 106), so the lane is the key minus that base, and a velocity-zero note-on (a note-off in disguise) is skipped. The application pass sets the flag on any real note that shares a lift's tick and lane, and touches nothing else. That last part is the whole safety argument: positions, lengths, counts and every other flag stay exactly as they were, so the optimiser is handed an identical chart.",[14,1118,1119],{},"Both hang off a single line in the Fortnite note builder, run right after the normal note pass:",[73,1121,1123],{"className":1073,"code":1122,"language":1075,"meta":81,"style":81},"const auto notes = notes_from_event_track(event_track, {}, TrackType::FortniteFestival); \u002F\u002F [!code --]\nauto notes = notes_from_event_track(event_track, {}, TrackType::FortniteFestival); \u002F\u002F [!code ++]\napply_fortnite_lifts(notes, fortnite_lifts_from_midi(midi_track)); \u002F\u002F [!code ++]\n",[62,1124,1125,1130,1135],{"__ignoreMap":81},[107,1126,1127],{"class":109,"line":110},[107,1128,1129],{},"const auto notes = notes_from_event_track(event_track, {}, TrackType::FortniteFestival); \u002F\u002F [!code --]\n",[107,1131,1132],{"class":109,"line":124},[107,1133,1134],{},"auto notes = notes_from_event_track(event_track, {}, TrackType::FortniteFestival); \u002F\u002F [!code ++]\n",[107,1136,1137],{"class":109,"line":143},[107,1138,1139],{},"apply_fortnite_lifts(notes, fortnite_lifts_from_midi(midi_track)); \u002F\u002F [!code ++]\n",[14,1141,1142,1143,1146],{},"That is the entire SightRead layer. From there CHOpt has to carry the flag from a parsed note to a drawn one. When the image builder converts a note it fills a per-lane lift mask on ",[62,1144,1145],{},"DrawnNote",", set only for the note's active lanes and only when the flag is present. The renderer reads that mask back out as a bitfield and decides what to draw. A note whose every active lane is a lift is drawn as triangles only, so its sprite is suppressed; a lift lane sitting over an otherwise normal note gets a triangle drawn on top of the usual sprite. The triangle is a right-pointing wedge in the lane's colour with a 1px black outline:",[73,1148,1150],{"className":1073,"code":1149,"language":1075,"meta":81,"style":81},"constexpr int HALF_WIDTH = 4;\nconstexpr int HALF_HEIGHT = 5;\n\nconst auto apex_x = x + HALF_WIDTH;\nconst auto base_x = x - HALF_WIDTH;\n\nm_image.draw_triangle(apex_x, cy, base_x, cy - HALF_HEIGHT,\n                      base_x, cy + HALF_HEIGHT, colour, 1.0F);\n",[62,1151,1152,1157,1162,1166,1171,1176,1180,1185],{"__ignoreMap":81},[107,1153,1154],{"class":109,"line":110},[107,1155,1156],{},"constexpr int HALF_WIDTH = 4;\n",[107,1158,1159],{"class":109,"line":124},[107,1160,1161],{},"constexpr int HALF_HEIGHT = 5;\n",[107,1163,1164],{"class":109,"line":143},[107,1165,742],{"emptyLinePlaceholder":741},[107,1167,1168],{"class":109,"line":159},[107,1169,1170],{},"const auto apex_x = x + HALF_WIDTH;\n",[107,1172,1173],{"class":109,"line":173},[107,1174,1175],{},"const auto base_x = x - HALF_WIDTH;\n",[107,1177,1178],{"class":109,"line":377},[107,1179,742],{"emptyLinePlaceholder":741},[107,1181,1182],{"class":109,"line":389},[107,1183,1184],{},"m_image.draw_triangle(apex_x, cy, base_x, cy - HALF_HEIGHT,\n",[107,1186,1187],{"class":109,"line":405},[107,1188,1189],{},"                      base_x, cy + HALF_HEIGHT, colour, 1.0F);\n",[14,1191,1192],{},"Regression on both engines confirmed non-Festival output byte-identical and Festival paths unchanged.",[38,1194,1196],{"id":1195},"why-it-stays-closed","Why It Stays Closed",[14,1198,1199],{},"I won't be releasing the client or its source, and the reason is the project's own design. Everything here works from the outside: it reads the screen, tails the log, and captures my own keys. Those are exactly the primitives an autoplay cheat needs, minus one step I have no interest in providing. Festival's leaderboards already carry obvious cheaters and nothing much seems to happen to them; I'm not adding a starter kit.",[14,1201,1202],{},"And the song that started all of this? Thanks to the client I can read Flames lead consistently now, and I full-combo it in there almost every run. In Fortnite itself I still haven't, because somewhere in the last two minutes my nerves show up, every time, and that, for the life of me, is not something a practice client can fix.",[1204,1205,1206],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sH9Dd, html code.shiki .sH9Dd{--shiki-default:var(--color-text)}html pre.shiki code .svQrX, html code.shiki .svQrX{--shiki-default:var(--color-muted)}html pre.shiki code .sosU6, html code.shiki .sosU6{--shiki-default:var(--color-muted);--shiki-default-font-style:italic}html pre.shiki code .s_Mgl, html code.shiki .s_Mgl{--shiki-default:var(--color-body)}html pre.shiki code .sneuw, html code.shiki .sneuw{--shiki-default:var(--code-literal)}html pre.shiki code .sRQ_c, html code.shiki .sRQ_c{--shiki-default:var(--code-string)}",{"title":81,"searchDepth":124,"depth":124,"links":1208},[1209,1210,1211,1212,1213,1214,1218,1224,1225,1226],{"id":40,"depth":124,"text":41},{"id":56,"depth":124,"text":57},{"id":216,"depth":124,"text":217},{"id":429,"depth":124,"text":430},{"id":445,"depth":124,"text":446},{"id":472,"depth":124,"text":473,"children":1215},[1216,1217],{"id":559,"depth":143,"text":560},{"id":575,"depth":143,"text":576},{"id":597,"depth":124,"text":598,"children":1219},[1220,1221,1222,1223],{"id":604,"depth":143,"text":605},{"id":828,"depth":143,"text":829},{"id":983,"depth":143,"text":984},{"id":998,"depth":143,"text":999},{"id":1014,"depth":124,"text":1015},{"id":1028,"depth":124,"text":1029},{"id":1195,"depth":124,"text":1196},"md",{"canListen":1229},false,"\u002Fposts\u002Freverse-engineering-fortnite-festival",{"title":6,"description":81},"posts\u002Freverse-engineering-fortnite-festival","My process building a custom client with Fortnite Festival's behaviour, as the game has no Practice or Replay Mode. ","published","gvLbW7q9x5NlvBLjuUenSIBlXCwPRT8r1_sf9xdvB28",{"id":1237,"title":1238,"body":1239,"description":1243,"extension":1227,"meta":1986,"navigation":741,"order":124,"path":1987,"seo":1988,"stem":1989,"summary":1990,"visibility":1234,"__hash__":1991},"posts\u002Fposts\u002Fdenuvo.md","Defeating Denuvo from Ring -1",{"type":8,"value":1240,"toc":1975},[1241,1244,1247,1251,1254,1261,1264,1268,1271,1295,1298,1306,1309,1313,1343,1346,1349,1353,1356,1359,1362,1365,1382,1415,1439,1442,1446,1456,1488,1498,1506,1510,1518,1521,1571,1574,1591,1595,1598,1627,1745,1755,1759,1769,1811,1829,1838,1883,1904,1907,1910,1914,1917,1959,1962,1965],[14,1242,1243],{},"A few months ago I wrote about BadUpdate, a software-only hypervisor exploit on the Xbox 360. It exploits an encrypted memory side channel to race the LZX decompressor and write forged data into hypervisor memory. The whole model rested on one assumption - that encrypted memory was unobservable from usermode. BadUpdate proved it wrong by turning the hypervisor's own update-processing path into an encryption oracle.",[14,1245,1246],{},"By early 2026 a similar architectural move had matured on PC, this time against Denuvo. Denuvo's runtime performance cost varies by title - undetectable in some, measurable in others - and the launches where it lands hardest tend to be the ones least able to absorb it. Watch Dogs: Legion is the case I keep coming back to: shipped with Denuvo in 2020 alongside a poor DX12 implementation, ran horrifically on hardware that exceeded its recommended specs, reception never recovered. Publishers routinely strip Denuvo from older releases once the launch window passes, and removal benchmarks across the current hypervisor-bypass wave show measurable FPS, VRAM, and memory footprint improvements on specific titles. Shipping protection that degrades the experience for paying customers, on a title that needs every advantage it can get, is a poor call.",[38,1248,1250],{"id":1249},"denuvo","Denuvo",[14,1252,1253],{},"Denuvo is anti-tamper DRM owned by Irdeto, deployed on most AAA PC releases over the past decade. Its job is to make cracking expensive enough that publishers extract most of their revenue from the launch window before a working pirate copy exists. The protection works through code virtualisation - sections of the executable get translated into custom bytecode interpreted by a VM inside the binary - combined with hardware fingerprinting that ties activation to specific machine identifiers, and integrity checks that catch modifications to the executable.",[14,1255,1256,1257,1260],{},"Traditional cracking is slow. Removing Denuvo means reversing the VM, understanding the bytecode, identifying and patching every check, and avoiding integrity verification. Months of work per title. The first full traditional crack of ",[818,1258,1259],{},"Resident Evil: Requiem"," (released 27th February 2026) shipped 40 days after launch - the first 2026 Denuvo title to fall to a traditional crack at all. Forty days is fast. Forty days is also outside the launch window where most of the revenue lands.",[14,1262,1263],{},"The hypervisor bypass shipped within hours of the same game's release.",[38,1265,1267],{"id":1266},"ring-1","Ring -1",[14,1269,1270],{},"Intel shipped the first VT-x silicon on 14th November 2005, in the Pentium 4 Model 662 and Model 672. AMD shipped AMD-V the following year. Both add a hardware execution mode that sits structurally below the four traditional privilege rings. Intel calls this VMX root mode; the hypervisor runs there. The operating system, including its kernel at ring 0, runs in VMX non-root mode.",[14,1272,1273,1274,1280,1281,1287,1288,1294],{},"Transitions between the two are called VM entry (root to non-root) and VM exit (non-root to root). A per-VCPU data structure called the VMCS - Virtual Machine Control Structure - manages the transitions and configures, among other things, which guest instructions cause a VM exit. The bitmap is granular: you can configure ",[89,1275,1277],{"as":1276},"C P U I D",[62,1278,1279],{},"CPUID"," to trap, ",[89,1282,1284],{"as":1283},"R D T S C",[62,1285,1286],{},"RDTSC"," to trap, specific ",[89,1289,1291],{"as":1290},"M S R",[62,1292,1293],{},"MSR"," reads to trap, I\u002FO port accesses to trap. Each configured instruction in the guest causes the CPU to save guest state, transfer control to the hypervisor's exit handler, and resume the guest only after the hypervisor returns. The hypervisor can modify register values during the exit. The guest sees those modified values as if they came from the silicon.",[14,1296,1297],{},"This is a hardware trap-and-emulate primitive. It exists for legitimate virtualisation - VMware, Hyper-V, KVM, and the Microsoft Virtualization-Based Security stack all use it. So does HyperDbg, an open-source hypervisor-based debugger. So does the Denuvo bypass.",[14,1299,1300,1301,1305],{},"The consequence: when something runs at VMX root mode, anything in the guest (the kernel, ring 0 drivers, PatchGuard, code integrity verification) is observable and modifiable from below. The guest cannot observe the host. ",[89,1302,1303],{"as":1276},[62,1304,1279],{}," has bits intended to indicate hypervisor presence, but those bits are set by software - a hypervisor that wants to hide can clear them. A hypervisor that wants to lie about CPU brand or timestamp counter values can return whatever it wants on the corresponding VM exits.",[14,1307,1308],{},"The hardware does not enforce truth about the hardware. This is the whole game.",[38,1310,1312],{"id":1311},"the-bypass","The Bypass",[14,1314,1315,1316,1320,1321,1324,1325,1327,1328,183,1331,1334,1335,1337,1338,1342],{},"The technique has a discrete origin. In December 2025, the scene group ",[89,1317,1319],{"as":1318},"M K Dev","MKDEV"," released a proof-of-concept hypervisor bypass against ",[818,1322,1323],{},"Persona 5 Royal",", alongside documentation describing the architecture. Per coverage of the release, ",[89,1326,1319],{"as":1318}," claimed about two days of work and called the approach \"10,000 times easier\" than reversing Denuvo's VM directly. By early 2026, refined releases - principally from Kirigiri - had productized the technique into day-zero releases against ",[818,1329,1330],{},"Borderlands 4",[818,1332,1333],{},"Crimson Desert",", and ",[818,1336,1259],{},". Other scene figures like ",[89,1339,1341],{"as":1340},"Zero X Zeon","0xZeOn"," and sagerao applied the same approach to further titles.",[14,1344,1345],{},"The bypass loads a custom hypervisor under Windows at boot, configures it to trap every CPU instruction Denuvo uses to fingerprint the system, and returns forged values matching a pre-generated license token. No VM obfuscation reversal, no binary patching, no integrity check tampering. From inside Denuvo's threat model, the host is a normal PC that happens to own a copy of the game.",[14,1347,1348],{},"For this to work, the hypervisor has to load. That's where the cost shows up.",[38,1350,1352],{"id":1351},"removing-the-windows-hypervisor","Removing the Windows Hypervisor",[14,1354,1355],{},"Modern Windows defends ring 0 with several layers wrapped under the umbrella of Virtualization-Based Security. PatchGuard verifies critical kernel structures at random intervals and bugchecks the system if it detects tampering. Driver Signature Enforcement (DSE) prevents the kernel from loading drivers that aren't signed by Microsoft or a trusted certificate authority. Hypervisor-Enforced Code Integrity (HVCI) runs code integrity checks in a Hyper-V partition that ring 0 can't reach. Credential Guard isolates authentication data. HyperGuard backs PatchGuard itself with VBS protection.",[14,1357,1358],{},"Most of these depend on the Windows hypervisor, which boots before the kernel using hardware virtualization extensions - VT-x on Intel, AMD-V on AMD. PatchGuard predates VBS and runs in the kernel on its own; the rest live above the hypervisor. The Windows hypervisor takes VMX root mode for itself and runs the kernel as a guest. VBS components live in a higher Virtual Trust Level (VTL1) the normal kernel can't reach.",[14,1360,1361],{},"A self-signed kernel driver from a scene release does not load on this configuration, and not because of DSE alone. The deeper architectural problem is that two bare-metal hypervisors cannot stack on x86 in the way the bypass needs. Nested virtualization exists - Hyper-V VMs can run their own Hyper-V instances - but Microsoft restricts it to its own hypervisor in Hyper-V guest configurations. The Windows hypervisor does not pass the hardware virtualization extensions through to its guest OS. Any third-party hypervisor that wants direct VT-x or AMD-V access has to replace the Windows hypervisor entirely. VMware Workstation hit this wall in 15.5.5 (May 2020) and added a User-Level Monitor mode that routes through the Windows Hypervisor Platform API instead of taking silicon when Hyper-V is active.",[14,1363,1364],{},"The Denuvo bypass faces the same constraint. It cannot run alongside the Windows hypervisor, only instead of it.",[14,1366,1367,1368,1374,1375,1381],{},"The user-side preparation reflects that. Scene releases ship a script (often named ",[89,1369,1371],{"as":1370},"V B S dot C M D",[62,1372,1373],{},"VBS.cmd",") that toggles registry keys and bcdedit settings to disable VBS, HVCI, Credential Guard, Hyper-V, and the boot-time hypervisor launch (",[89,1376,1378],{"as":1377},"B C D edit set hypervisor launch type off",[62,1379,1380],{},"bcdedit \u002Fset hypervisorlaunchtype off","). The user reboots and presses F7 at the Windows boot menu to disable DSE for that session - Microsoft's own boot-time option, intended for driver developers. With VBS torn down and DSE disabled for the boot, the bypass driver loads, claims VMX root mode for itself, and the game starts. After the session, running the script again reverts the changes and a reboot returns the system to its protected state.",[14,1383,1384,1385,183,1391,190,1397,1403,1404,1410,1411,1414],{},"EfiGuard by Mattiwatti is an alternative path that some users prefer. It's a UEFI bootkit that patches ",[89,1386,1388],{"as":1387},"bootmgfw dot efi",[62,1389,1390],{},"bootmgfw.efi",[89,1392,1394],{"as":1393},"winload dot efi",[62,1395,1396],{},"winload.efi",[89,1398,1400],{"as":1399},"ntoskrnl dot exe",[62,1401,1402],{},"ntoskrnl.exe"," before ",[89,1405,1407],{"as":1406},"Exit Boot Services",[62,1408,1409],{},"ExitBootServices()"," is called, neutralising PatchGuard and DSE at boot. More surgical than the script-and-F7 approach, but only against PatchGuard and DSE. With HVCI enabled, EfiGuard's patches accomplish nothing useful: integrity checking moves to the secure kernel, and EfiGuard's ",[62,1412,1413],{},"SetVariable"," runtime DSE toggle bugchecks Windows on use. Users still have to disable VBS through Windows settings before a hypervisor bypass driver will load - which is most of the work.",[14,1416,1417,1418,1424,1425,1431,1432,1438],{},"A third path lives inside the bypass DLL itself. Independent reverse engineering of the KIRIGIRI release shows it can disable DSE at runtime by calling ",[89,1419,1421],{"as":1420},"N T Set System Environment Value Ex",[62,1422,1423],{},"NtSetSystemEnvironmentValueEx"," with a magic value (",[89,1426,1428],{"as":1427},"hex D E A D C zero D E",[62,1429,1430],{},"0xDEADC0DE",") that tunnels through a UEFI runtime variable into kernel memory. This is the same SetVariable backdoor technique EfiGuard exposes, invoked from usermode by the crack rather than from a UEFI bootkit. Either path produces the same result: ",[89,1433,1435],{"as":1434},"g C I Enabled",[62,1436,1437],{},"g_CiEnabled"," flipped, unsigned drivers load.",[14,1440,1441],{},"Either way, after the disable step, Windows will load any unsigned ring 0 driver, and the silicon's virtualization extensions are available for the bypass to claim.",[38,1443,1445],{"id":1444},"the-dll-hijack","The DLL Hijack",[14,1447,1448,1449,1455],{},"Once Windows is willing to load the driver, the bypass needs to bootstrap from inside the game process. The technique is a DLL proxy hijack against ",[89,1450,1452],{"as":1451},"A M D A G S x sixty-four dot D L L",[62,1453,1454],{},"amd_ags_x64.dll",", the AMD GPU Services SDK that AAA games statically import for GPU feature queries.",[14,1457,1458,1459,1463,1464,1470,1471,1475,1476,1482,1483,1487],{},"The release ships a patched proxy version of ",[89,1460,1461],{"as":1451},[62,1462,1454],{}," that gets dropped into the game's directory, replacing the real one. The genuine AMD SDK is renamed to ",[89,1465,1467],{"as":1466},"A M D A G S x sixty-four dot org",[62,1468,1469],{},"amd_ags_x64.org"," and shipped alongside it. At launch, the Windows loader walks the executable's PE imports, sees ",[89,1472,1473],{"as":1451},[62,1474,1454],{}," listed, and resolves it to the patched proxy. The proxy's ",[89,1477,1479],{"as":1478},"D L L Main",[62,1480,1481],{},"DllMain"," does two things: loads the original ",[89,1484,1485],{"as":1466},[62,1486,1469],{}," so the game's GPU calls still work, and pulls in the bypass DLL.",[14,1489,1490,1491,1497],{},"The proxy declares the bypass DLL as a static PE import. The Windows loader resolves it during normal import resolution - no ",[89,1492,1494],{"as":1493},"Load Library",[62,1495,1496],{},"LoadLibrary"," call from scene code, no thread injection, no shellcode trampoline. The bypass loads because the loader follows the standard rules for a DLL it has no reason to suspect. From an anti-cheat perspective, the only signal is a static import on a DLL the game already loads.",[14,1499,1500,1501,1505],{},"Many AAA titles ship ",[89,1502,1503],{"as":1451},[62,1504,1454],{}," as a static PE import - particularly those with AMD partner integrations - which makes the same proxy viable across multiple games without per-game work. AMD also distributes AGS as a static library that links into the executable directly, in which case there is no separate DLL on disk and the proxy hijack doesn't apply.",[38,1507,1509],{"id":1508},"backend-selection","Backend Selection",[14,1511,1512,1513,1517],{},"The first runtime decision inside the bypass DLL is which hypervisor backend to load. The DLL reads the CPU vendor string via ",[89,1514,1515],{"as":1276},[62,1516,1279],{}," leaf 0 and dispatches based on the result:",[14,1519,1520],{},"{\u002F* tts: Table of backend selection. Row 1: Authentic AMD vendor string loads Simple SVM dot sys. Row 2: Genuine Intel vendor string loads hyper K D dot sys. *\u002F}",[222,1522,1523,1533],{},[225,1524,1525],{},[228,1526,1527,1530],{},[231,1528,1529],{},"Vendor string",[231,1531,1532],{},"Driver loaded",[241,1534,1535,1553],{},[228,1536,1537,1545],{},[246,1538,1539],{},[89,1540,1542],{"as":1541},"Authentic A M D",[62,1543,1544],{},"AuthenticAMD",[246,1546,1547],{},[89,1548,1550],{"as":1549},"Simple SVM dot sys",[62,1551,1552],{},"SimpleSvm.sys",[228,1554,1555,1563],{},[246,1556,1557],{},[89,1558,1560],{"as":1559},"Genuine Intel",[62,1561,1562],{},"GenuineIntel",[246,1564,1565],{},[89,1566,1568],{"as":1567},"hyper K D dot sys",[62,1569,1570],{},"hyperkd.sys",[14,1572,1573],{},"These are different implementations because Intel VT-x and AMD-V are different ISAs. They have similar capabilities but different control structures - VMCS on Intel, VMCB on AMD - different instructions for entering and exiting virtualisation mode, and different MSRs to configure. A bypass that wants to support both vendors has to ship two backends.",[14,1575,1576,1577,1583,1584,1590],{},"The selected driver registers as a Windows service via ",[89,1578,1580],{"as":1579},"Create Service W",[62,1581,1582],{},"CreateServiceW"," under the name ",[89,1585,1587],{"as":1586},"denuvo kirigiri",[62,1588,1589],{},"denuvo_kirigiri"," and starts. Hardware virtualisation is exclusive: only one root-mode hypervisor can hold VT-x or AMD-V at a time. The bypass logic checks for an existing hypervisor; if Hyper-V or another VMM is still present, it stops it before the chosen driver initialises. With VBS disabled by the earlier preparation step, the slot is free.",[38,1592,1594],{"id":1593},"hyperdbg","HyperDbg",[14,1596,1597],{},"The Intel-side hypervisor in current bypass releases is built on HyperDbg, an open-source hypervisor-based debugger by Mohammad Sina Karvandi. HyperDbg uses Intel VT-x and Extended Page Tables, and avoids standard Windows debugging APIs by design so that anti-debug protections aimed at conventional debuggers can't see it. Its purpose is to debug software that resists being debugged - malware, packers, anti-cheat engines. The architectural symmetry with the Denuvo case is direct: \"invisible to anti-cheat\" and \"invisible to Denuvo\" are the same property, and HyperDbg's design produces both.",[14,1599,1600,1601,1607,1608,1614,1615,1621,1622,1626],{},"The two driver names aren't a single project. ",[89,1602,1604],{"as":1603},"hyper K D",[62,1605,1606],{},"hyperkd"," is HyperDbg's kernel-mode shim; the real VMX engine lives in ",[89,1609,1611],{"as":1610},"hyper H V dot D L L",[62,1612,1613],{},"hyperhv.dll",", a modified build of upstream HyperDbg. ",[89,1616,1618],{"as":1617},"Simple SVM",[62,1619,1620],{},"SimpleSvm"," is unrelated - an independent AMD-V hypervisor by Satoshi Tanda. HyperDbg's own AMD codebase is RedDbg, which the bypass doesn't use. Both drivers are repackaged under the same Windows service name, ",[89,1623,1624],{"as":1586},[62,1625,1589],{},"; the Intel primitives come from the upstream debugger, the AMD primitives don't.",[14,1628,1629,1630,1636,1637,1643,1644,1650,1651,190,1657,1663,1664,1670,1671,1675,1676,1680,1681,1675,1685,1675,1689,1693,1694,1636,1700,1706,1707,1713,1714,1718,1719,1723,1724,1730,1731,1737,1738,1744],{},"HyperDbg has two operating modes. Default mode advertises itself as a hypervisor: CPUID leaf 1 sets the hypervisor present bit, leaf ",[89,1631,1633],{"as":1632},"hex 4 followed by seven zeroes",[62,1634,1635],{},"0x40000000"," returns ",[89,1638,1640],{"as":1639},"Hyper D B G",[62,1641,1642],{},"'HyperDbg'"," as the vendor string (encoded little-endian as ",[89,1645,1647],{"as":1646},"E P Y H, G B D R",[62,1648,1649],{},"'epyH gbDr'"," across ",[89,1652,1654],{"as":1653},"E B X",[62,1655,1656],{},"EBX",[89,1658,1660],{"as":1659},"E C X",[62,1661,1662],{},"ECX",", with ",[89,1665,1667],{"as":1666},"E D X",[62,1668,1669],{},"EDX"," zeroed - unlike the 12-byte ",[89,1672,1673],{"as":1559},[62,1674,1562],{},"\u002F:speak",[107,1677,1678],{"as":1541},[62,1679,1544],{}," form that uses ",[89,1682,1683],{"as":1653},[62,1684,1656],{},[107,1686,1687],{"as":1666},[62,1688,1669],{},[107,1690,1691],{"as":1659},[62,1692,1662],{},"), and leaf ",[89,1695,1697],{"as":1696},"hex 4 followed by six zeroes 1",[62,1698,1699],{},"0x40000001",[89,1701,1703],{"as":1702},"H V hash zero",[62,1704,1705],{},"'Hv#0'"," to indicate non-Microsoft. Transparent mode, gated on a flag the source calls ",[89,1708,1710],{"as":1709},"g check for footprints",[62,1711,1712],{},"g_CheckForFootprints",", hides all of this. The hypervisor present bit gets cleared. Leaves in the ",[89,1715,1716],{"as":1632},[62,1717,1635],{}," range return constant ",[89,1720,1721],{"as":1632},[62,1722,1635],{}," in all four registers - no usable vendor or interface data. The bypass enables transparent mode and adds a custom handler for the CPU brand string at leaves ",[89,1725,1727],{"as":1726},"hex 8 followed by six zeroes 2",[62,1728,1729],{},"0x80000002","-:speak",[107,1732,1734],{"as":1733},"hex 8 followed by six zeroes 4",[62,1735,1736],{},"0x80000004",", which transparent mode itself doesn't touch. That's where ",[89,1739,1741],{"as":1740},"Denuv OWO CPU at 1337 gigahertz",[62,1742,1743],{},"DenuvOWO CPU @ 1337 GHz"," lives. The string is in the bypass author's custom layer, not in upstream HyperDbg.",[14,1746,1747,1748,1754],{},"On initialisation, the driver allocates page-aligned VMXON and VMCS regions, enters VMX root mode via ",[89,1749,1751],{"as":1750},"V M X ON",[62,1752,1753],{},"VMXON",", and configures the VMCS for Windows as the guest. The default VMCS configuration in HyperDbg is minimal by design: IO bitmaps, MSR bitmaps, EPT, RDTSCP, INVPCID, XSAVES, and VPID. CPUID is intercepted because VMX always traps CPUID. RDTSC exiting is not enabled. The trapping surface is small because every trap is a potential timing-detection signature. Minimum trapping surface, minimum detection surface.",[38,1756,1758],{"id":1757},"what-gets-spoofed-and-what-doesnt","What Gets Spoofed (And What Doesn't)",[14,1760,1761,1762,1768],{},"The CPUID interception described above handles hypervisor presence and the CPU brand string. License token contents come back from a separate handler the bypass installs, fed by a pre-generated ",[89,1763,1765],{"as":1764},"tokens dot bin",[62,1766,1767],{},"tokens.bin"," written on first launch (more on this below).",[14,1770,1771,1772,183,1778,183,1784,1334,1790,1796,1797,1803,1804,1807,1808,1810],{},"CPUID also serves as a hyper-call channel from usermode. Custom magic-value leaves at ",[89,1773,1775],{"as":1774},"hex 6 9 6 9 6 9 6 9",[62,1776,1777],{},"0x69696969",[89,1779,1781],{"as":1780},"hex 1 3 3 7",[62,1782,1783],{},"0x1337",[89,1785,1787],{"as":1786},"hex 3 3 6 9 3 3",[62,1788,1789],{},"0x336933",[89,1791,1793],{"as":1792},"hex 4 1 4 1 4 1 4 1",[62,1794,1795],{},"0x41414141"," register the game's CR3 with the hypervisor, pass the target PID for ",[89,1798,1800],{"as":1799},"K USER SHARED DATA",[62,1801,1802],{},"KUSER_SHARED_DATA"," spoofing, store per-game configuration, and trigger hypervisor teardown respectively. Using CPUID rather than ",[62,1805,1806],{},"VMCALL"," is the quieter choice; guests issue CPUID constantly, while a guest-side ",[62,1809,1806],{}," is rare enough to draw heuristic attention.",[14,1812,1813,190,1817,1823,1824,1828],{},[89,1814,1815],{"as":1283},[62,1816,1286],{},[89,1818,1820],{"as":1819},"R D T S C P",[62,1821,1822],{},"RDTSCP"," detection deserves a separate treatment because the mechanism isn't what most writeups claim. Anti-VM detection issues ",[89,1825,1826],{"as":1283},[62,1827,1286],{}," before and after a known-fast operation, then checks the delta - VM-EXIT latency leaks through as elevated cycle counts. The standard countermove is to hook RDTSC and return values that don't reveal the exit overhead.",[14,1830,1831,1832,1837],{},"HyperDbg doesn't do that. Its default VMCS leaves RDTSC exiting disabled, which means RDTSC and RDTSCP run natively at hardware speed inside the guest. There is no VM-EXIT, no latency, nothing to detect. The mechanism is \"don't trap the instruction\" rather than \"trap it and lie about elapsed cycles.\" If RDTSC trapping is enabled (for explicit tracing commands), the emulation is a straight ",[89,1833,1834],{"as":1283},[62,1835,1836],{},"__rdtsc()"," passthrough, with proper TSC offsetting noted as a future improvement blocked by PatchGuard interactions.",[14,1839,1840,1841,1847,1848,1852,1853,1859,1860,183,1866,1872,1873,1877,1878,1882],{},"The timing-spoofing layer the bypass adds sits one level higher than the instruction. Independent reverse engineering of the bypass shows a kernel thread named ",[89,1842,1844],{"as":1843},"Counter Updater",[62,1845,1846],{},"CounterUpdater"," that runs in a loop and writes spoofed tick count and timestamp values into ",[89,1849,1850],{"as":1799},[62,1851,1802],{},", the page Windows maps at ",[89,1854,1856],{"as":1855},"hex 7 F F E followed by four zeroes",[62,1857,1858],{},"0x7FFE0000"," containing system time, tick count, and various OS state. Most Windows API timing calls (",[89,1861,1863],{"as":1862},"Get Tick Count",[62,1864,1865],{},"GetTickCount",[89,1867,1869],{"as":1868},"Query Performance Counter",[62,1870,1871],{},"QueryPerformanceCounter"," and friends) read from ",[89,1874,1875],{"as":1799},[62,1876,1802],{}," rather than issuing ",[89,1879,1880],{"as":1283},[62,1881,1286],{}," themselves, so spoofing the page spoofs all of them. The instruction stays a passthrough at the silicon. The page above it lies.",[14,1884,1885,1886,1896,1897,1903],{},"MSR spoofing in HyperDbg's transparent mode is inactive. The handler functions exist (",[89,1887,1889,1892,1893],{"as":1888},"Transparent Check And Modify MSR Read and Write",[62,1890,1891],{},"TransparentCheckAndModifyMsrRead","\u002F",[62,1894,1895],{},"Write",") but return without touching register values. Injecting ",[89,1898,1900],{"as":1899},"hash G P",[62,1901,1902],{},"#GP"," on the reserved hypervisor MSR range crashes Windows on Meteor Lake processors, because the OS expects synthetic timer MSRs to function, so the obvious approach is closed in the upstream code.",[14,1905,1906],{},"The bypass stacks the brand string spoofing in the hypervisor, IAT hooks and pre-generated license tokens in usermode, the proxy DLL chain, and the Goldberg Steam integration on top of HyperDbg. The hypervisor is upstream; the rest is what the scene wrote.",[14,1908,1909],{},"The load-bearing observation: every CPUID query in the validation chain is a CPU instruction the guest believes goes to silicon. The guest has no mechanism to verify that the CPU it's talking to is the real CPU. The hardware that exists for legitimate virtualisation does not give the guest a way to escape virtualisation, by design - that would defeat the point of running a hypervisor in the first place.",[38,1911,1913],{"id":1912},"tokens-and-goldberg","Tokens and Goldberg",[14,1915,1916],{},"Denuvo's license validation isn't only hardware fingerprinting. There's also a license token - a cryptographic blob that binds an activation to a specific machine and an authenticated Steam account. The token has to come from somewhere.",[14,1918,1919,1920,1923,1924,183,1930,183,1936,190,1942,1948,1949,1955,1956,1958],{},"The bypass DLL ships two pre-generated tokens, one per supported hypervisor backend. On first launch, the bypass writes the appropriate token to a ",[62,1921,1922],{},".bin"," file in the game directory. Delivery runs through usermode hooking rather than through the hypervisor: the bypass creates writable shadow copies of ",[89,1925,1927],{"as":1926},"ntdll dot dll",[62,1928,1929],{},"ntdll.dll",[89,1931,1933],{"as":1932},"kernel32 dot dll",[62,1934,1935],{},"kernel32.dll",[89,1937,1939],{"as":1938},"kernelbase dot dll",[62,1940,1941],{},"kernelbase.dll",[89,1943,1945],{"as":1944},"user32 dot dll",[62,1946,1947],{},"user32.dll",", patches the Import Address Tables in those copies, and replaces the entries in the PEB's three module lists so loader-walking detection sees the shadow copies, not the originals. Selected calls from the game then route through bypass DLL handlers before reaching the real implementations. The hook on ",[89,1950,1952],{"as":1951},"Create File W",[62,1953,1954],{},"CreateFileW"," redirects opens of Denuvo's license file to the fake ",[62,1957,1922],{},". Denuvo reads what looks like a valid license blob and proceeds. The scene pre-generated both tokens against a known set of forged hardware values - the same forged values the hypervisor returns on CPUID queries - so validation across the file-side and the hardware-side checks lines up by construction.",[14,1960,1961],{},"The IAT hooks also catch other checks that bottom out in user-mode APIs the hypervisor doesn't intercept (because the call doesn't translate to a trapping CPU instruction). The bypass needs that second layer of interception in usermode for those.",[14,1963,1964],{},"The token-to-environment binding is the part of the bypass that's fragile. If Irdeto rotates the cryptographic scheme used for token generation in a future Denuvo version, every existing pre-generated token becomes invalid overnight, and the scene has to regenerate them. A soft kill, not a hard one. The technique still works, but every release needs new tokens.",[14,1966,1967,1968,1974],{},"The Steam side is handled separately by a fork of the Goldberg emulator (GBE), which replaces ",[89,1969,1971],{"as":1970},"steam api 64 dot dll",[62,1972,1973],{},"steam_api64.dll"," with a proxy that emulates the Steam platform interface locally. Goldberg is unrelated to Denuvo. It's been around for years and exists for legitimate reasons - offline play, LAN multiplayer for older games, modding - but it's a necessary component because Denuvo's license validation cross-references with Steam ticket APIs, and those have to return something coherent.",{"title":81,"searchDepth":124,"depth":124,"links":1976},[1977,1978,1979,1980,1981,1982,1983,1984,1985],{"id":1249,"depth":124,"text":1250},{"id":1266,"depth":124,"text":1267},{"id":1311,"depth":124,"text":1312},{"id":1351,"depth":124,"text":1352},{"id":1444,"depth":124,"text":1445},{"id":1508,"depth":124,"text":1509},{"id":1593,"depth":124,"text":1594},{"id":1757,"depth":124,"text":1758},{"id":1912,"depth":124,"text":1913},{"canListen":741},"\u002Fposts\u002Fdenuvo",{"title":1238,"description":1243},"posts\u002Fdenuvo","You can't fight ring -1 with ring 0.","Z2sNENVUM1R95il4MUuUuoGaScRikP_TYWHyJYvyf1w",{"id":1993,"title":1994,"body":1995,"description":2741,"extension":1227,"meta":2742,"navigation":741,"order":143,"path":2743,"seo":2744,"stem":2745,"summary":2746,"visibility":1234,"__hash__":2747},"posts\u002Fposts\u002Fskowt-cc-architecture.md","Type What You Remember",{"type":8,"value":1996,"toc":2727},[1997,2010,2013,2017,2059,2067,2094,2098,2101,2109,2120,2127,2131,2138,2145,2155,2172,2179,2183,2190,2194,2207,2260,2277,2393,2410,2435,2507,2511,2514,2521,2528,2531,2534,2538,2545,2548,2555,2562,2569,2573,2576,2583,2591,2600,2604,2613,2616,2623,2635,2648,2655,2666,2670,2677,2690,2696,2699,2714,2717,2721,2724],[14,1998,1999,2004,2005,2009],{},[24,2000,2003],{"href":2001,"rel":2002},"https:\u002F\u002Fskowt.cc",[28],"skowt.cc"," is an asset database for game art. Contributors upload assets, a lot of them from ",[89,2006,2008],{"as":2007},"gah-cha","gacha"," games, and other people pull them back down, often a few hundred at once. Traffic is spiky and downloads arrive in batches, so the whole thing runs on one bias: keep the request path cheap, and never make the API do work it can hand to something else.",[14,2011,2012],{},"The site has run since 2022, first as a raw file index, then as wanderer.moe, now as skowt. Same single database the whole way through. This is what holds it up now, and the part I'm proudest of is the search, so that's where this goes deepest.",[38,2014,2016],{"id":2015},"layout","Layout",[14,2018,2019,2020,2023,2024,2029,2030,2033,2034,2039,2040,2045,2046,2051,2052,2055,2056,885],{},"It's a monorepo. ",[62,2021,2022],{},"apps\u002Fweb"," is a ",[24,2025,2028],{"href":2026,"rel":2027},"https:\u002F\u002Ftanstack.com",[28],"TanStack Start"," SPA, ",[62,2031,2032],{},"apps\u002Fserver"," is an ",[24,2035,2038],{"href":2036,"rel":2037},"https:\u002F\u002Felysiajs.com",[28],"Elysia"," process on ",[24,2041,2044],{"href":2042,"rel":2043},"https:\u002F\u002Fbun.sh",[28],"Bun"," mounting a ",[24,2047,2050],{"href":2048,"rel":2049},"https:\u002F\u002Ftrpc.io",[28],"tRPC"," adapter, and ",[62,2053,2054],{},"apps\u002Fasset-redirect"," is a Cloudflare Worker that owns the public read path so image reads resolve at the edge and never enter the API. Two more apps do inference: a small CPU sidecar that turns a search query into a vector, and a GPU image on rented hardware that does the per-asset model work. Everything the apps don't own lives in ",[62,2057,2058],{},"packages\u002F*",[581,2060,2062],{"caption":2061},"The serving side. Images and zip packs resolve at the edge worker and never touch the API; everything else is one typed tRPC client into the Elysia process, which owns the three stores and streams its telemetry out the back.",[2063,2064],"flow-diagram",{":columns":2065,":edges":2066},"[[{\"id\":\"browser\",\"title\":\"browser\",\"sub\":\"apps\u002Fweb\"},{\"id\":\"bot\",\"title\":\"discord bot\",\"sub\":\"slash uploads\"}],[{\"id\":\"worker\",\"title\":\"edge worker\",\"sub\":\"apps\u002Fasset-redirect\"},{\"id\":\"api\",\"title\":\"api\",\"sub\":\"bun + elysia\"}],[{\"id\":\"r2\",\"title\":\"r2\",\"sub\":\"files\"},{\"id\":\"turso\",\"title\":\"turso\",\"sub\":\"catalogue\"},{\"id\":\"redis\",\"title\":\"redis\",\"sub\":\"sessions, queues\"}]]","[{\"from\":\"browser\",\"to\":\"worker\",\"label\":\"images\"},{\"from\":\"browser\",\"to\":\"api\",\"label\":\"trpc\"},{\"from\":\"bot\",\"to\":\"api\",\"label\":\"same ingest\"},{\"from\":\"worker\",\"to\":\"r2\"},{\"from\":\"api\",\"to\":\"r2\",\"label\":\"presigned\"},{\"from\":\"api\",\"to\":\"turso\"},{\"from\":\"api\",\"to\":\"redis\"}]",[14,2068,2069,2070,2075,2076,2081,2082,2087,2088,2093],{},"Three stores, each holding what it's best at. Relational data lives in ",[24,2071,2074],{"href":2072,"rel":2073},"https:\u002F\u002Fturso.tech",[28],"Turso"," (hosted ",[24,2077,2080],{"href":2078,"rel":2079},"https:\u002F\u002Fgithub.com\u002Ftursodatabase\u002Flibsql",[28],"libSQL",", a SQLite fork with a server in front). That choice pays off twice: search can be a virtual table in the same database rather than a separate service, and it's also why a naive session check hurts, because every read is a network round-trip. Files live in ",[24,2083,2086],{"href":2084,"rel":2085},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Fr2\u002F",[28],"Cloudflare R2",". ",[24,2089,2092],{"href":2090,"rel":2091},"https:\u002F\u002Fredis.io",[28],"Redis"," holds the ephemeral state: sessions, rate-limit windows, download batches, and a cache in front of the hot reads.",[38,2095,2097],{"id":2096},"the-upload-never-touches-the-api","The Upload Never Touches the API",[14,2099,2100],{},"The API process never handles file bytes. An upload is a three-step handshake, and the server only ever touches the first and last step.",[581,2102,2104],{"caption":2103},"The presigned handshake. The API validates the claim, hands back a short-lived URL, and only believes anything about the file once it can read the object back itself. A large upload never streams through Elysia, never sits in its memory, never counts against its concurrency.",[2105,2106],"sequence-flow",{":actors":2107,":steps":2108},"[{\"id\":\"browser\",\"title\":\"browser\",\"sub\":\"apps\u002Fweb\"},{\"id\":\"api\",\"title\":\"api\",\"sub\":\"apps\u002Fserver\"},{\"id\":\"r2\",\"title\":\"r2\",\"sub\":\"the bucket\"}]","[{\"from\":\"browser\",\"to\":\"api\",\"label\":\"requestUpload\",\"note\":\"name, mime, size, where it belongs\"},{\"at\":\"api\",\"label\":\"pending row + short-lived presigned url\"},{\"from\":\"browser\",\"to\":\"r2\",\"label\":\"PUT the file\",\"note\":\"bytes go straight to the bucket\"},{\"from\":\"browser\",\"to\":\"api\",\"label\":\"commitUpload\"},{\"from\":\"api\",\"to\":\"r2\",\"label\":\"read the object back\"},{\"at\":\"api\",\"label\":\"magic bytes, hash, dims, variants\"}]",[14,2110,2111,2112,2115,2116,2119],{},"A presigned PUT pins nothing: not the ",[62,2113,2114],{},"Content-Type",", not the byte count. The browser can declare a 2MB PNG and push 400MB of arbitrary bytes to the URL. So ",[62,2117,2118],{},"commitUpload"," treats the object as hostile and re-derives the truth off one read: it stats the size (the only real size enforcement in the flow), reads the header bytes and rejects anything that isn't an image, hashes the content and checks it against every existing asset so the same bytes can't enter twice, reads the dimensions, and generates the derived thumbnail and 1024px preview fail-closed. A commit that can't produce both variants is rejected outright rather than letting a broken original in. Every terminal rejection purges the object and the pending row, so the moderation queue never shows a ghost with no bytes behind it.",[14,2121,2122,2123,2126],{},"Untrusted uploads write to a ",[62,2124,2125],{},"limbo\u002F"," prefix the edge worker refuses to route at all, so a pending upload physically sits somewhere unservable until a moderator copies it to the public prefix. That split is enforced by storage layout, not by an access check I have to remember to write.",[38,2128,2130],{"id":2129},"the-read-path-is-one-pure-function","The Read Path Is One Pure Function",[14,2132,2133,2134,2137],{},"For a long time downloads had no gate: anyone could construct a CDN URL and skip the counter, and the thumbnails leaned on Cloudflare's image resizing, which ran into the hundreds some months. I finally moved the whole read path behind a Worker, and the public read policy is one pure ",[62,2135,2136],{},"decide()"," function.",[581,2139,2141],{"caption":2140},"The read policy. One function classifies every request before the bucket is touched, so R2 only ever sees reads the policy already approved, and every read that does happen is cached at the edge because each one is billed.",[2063,2142],{":columns":2143,":edges":2144},"[[{\"id\":\"req\",\"title\":\"pack.skowt.cc\",\"sub\":\"every image request\"}],[{\"id\":\"decide\",\"title\":\"decide()\",\"sub\":\"one pure function\"}],[{\"id\":\"variant\",\"title\":\"derived variants\",\"sub\":\"streamed + cached\"},{\"id\":\"original\",\"title\":\"raw originals\",\"sub\":\"301 to preview\"},{\"id\":\"deny\",\"title\":\"limbo\u002F + unknown\",\"sub\":\"404, bucket untouched\",\"em\":true}]]","[{\"from\":\"req\",\"to\":\"decide\",\"label\":\"host + path\"},{\"from\":\"decide\",\"to\":\"variant\"},{\"from\":\"decide\",\"to\":\"original\"},{\"from\":\"decide\",\"to\":\"deny\"}]",[14,2146,2147,2148,2150,2151,2154],{},"The allowlist is derived variants only. A request for a raw original 301s to the preview; ",[62,2149,2125],{}," has no route; everything else 404s before the bucket is read. Actual downloads are presigned now: ",[62,2152,2153],{},"downloads.generate"," verifies the ids, records the batch in Redis, bumps the counters, and mints a short-lived presigned GET per asset — recording and delivery in one call, so an honest client can't skip the counter.",[14,2156,2157,2158,2161,2162,2167,2168,2171],{},"Whole-game packs bake nothing. ",[62,2159,2160],{},"downloads.generatePack"," builds a manifest from the live catalogue at request time and returns one ",[24,2163,2166],{"href":2164,"rel":2165},"https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FHMAC",[28],"HMAC","-signed URL; the worker verifies it, pulls the manifest back from the API, and streams a store-mode zip. Store mode means the archive size is computable up front, so the browser gets a real ",[62,2169,2170],{},"content-length"," and an honest progress bar instead of chunked encoding's shrug.",[581,2173,2175],{"caption":2174},"Why one game can't always be one pack. Every zip entry costs one R2 subrequest, the Workers runtime caps them at 10,000 per request, and strinova alone runs to ~16,000 files, so oversized games get steered to per-category packs.",[585,2176],{":data":2177,"format":2178},"[{\"label\":\"pack cap\",\"sublabel\":\"self-imposed\",\"value\":9500},{\"label\":\"workers cap\",\"sublabel\":\"10k subrequests\",\"value\":10000},{\"label\":\"strinova\",\"sublabel\":\"one game's files\",\"value\":16000,\"em\":true}]","int",[38,2180,2182],{"id":2181},"search","Search",[14,2184,2185,2186,2189],{},"Searching by filename is one filter on the catalogue query. Searching for ",[818,2187,2188],{},"what's actually in the picture"," is a different problem, and it's the part that got interesting.",[557,2191,2193],{"id":2192},"filename-search-fts5-trigram-not-like","Filename search: FTS5 trigram, not LIKE",[14,2195,2196,2197,2200,2201,2206],{},"Name search used to be ",[62,2198,2199],{},"LIKE '%term%'"," — a full scan, case-sensitive in ways that surprise people. It's now an ",[24,2202,2205],{"href":2203,"rel":2204},"https:\u002F\u002Fsqlite.org\u002Ffts5.html",[28],"FTS5"," virtual table with the trigram tokenizer, living inside the same libSQL database:",[73,2208,2212],{"className":2209,"code":2210,"language":2211,"meta":81,"style":81},"language-sql shiki shiki-themes dromzeh","CREATE VIRTUAL TABLE IF NOT EXISTS asset_fts\n  USING fts5(asset_id UNINDEXED, name, tokenize='trigram')\n","sql",[62,2213,2214,2237],{"__ignoreMap":81},[107,2215,2216,2219,2222,2225,2228,2231,2234],{"class":109,"line":110},[107,2217,2218],{"class":113},"CREATE",[107,2220,2221],{"class":325}," VIRTUAL ",[107,2223,2224],{"class":113},"TABLE",[107,2226,2227],{"class":113}," IF",[107,2229,2230],{"class":113}," NOT",[107,2232,2233],{"class":113}," EXISTS",[107,2235,2236],{"class":325}," asset_fts\n",[107,2238,2239,2242,2245,2248,2251,2254,2257],{"class":109,"line":124},[107,2240,2241],{"class":113},"  USING",[107,2243,2244],{"class":325}," fts5(asset_id UNINDEXED, ",[107,2246,2247],{"class":113},"name",[107,2249,2250],{"class":325},", tokenize",[107,2252,2253],{"class":120},"=",[107,2255,2256],{"class":797},"'trigram'",[107,2258,2259],{"class":325},")\n",[14,2261,2262,2263,2266,2267,2269,2270,2273,2274,2276],{},"Trigram is the specific pick because it reproduces the substring feel of ",[62,2264,2265],{},"LIKE"," while being indexed and case-insensitive, so swapping ",[62,2268,2265],{}," for ",[62,2271,2272],{},"MATCH"," is behaviour-preserving. Terms under three characters can't be trigram-tokenized, so the query layer keeps a ",[62,2275,2265],{}," fallback and switches to the index at three:",[73,2278,2282],{"className":2279,"code":2280,"language":2281,"meta":81,"style":81},"language-ts shiki shiki-themes dromzeh","if (term.length >= 3) {\n  return sql`${asset.id} IN (SELECT asset_id FROM asset_fts WHERE name MATCH ${escapeFtsMatch(term)})`;\n}\nreturn like(asset.name, `%${term}%`);\n","ts",[62,2283,2284,2310,2356,2360],{"__ignoreMap":81},[107,2285,2286,2289,2292,2295,2297,2300,2303,2305,2308],{"class":109,"line":110},[107,2287,2288],{"class":113},"if",[107,2290,2291],{"class":120}," (",[107,2293,2294],{"class":325},"term",[107,2296,885],{"class":120},[107,2298,2299],{"class":325},"length ",[107,2301,2302],{"class":120},">=",[107,2304,913],{"class":751},[107,2306,2307],{"class":120},")",[107,2309,121],{"class":120},[107,2311,2312,2315,2318,2321,2324,2327,2329,2332,2335,2338,2340,2343,2345,2347,2349,2351,2354],{"class":109,"line":124},[107,2313,2314],{"class":113},"  return",[107,2316,2317],{"class":113}," sql",[107,2319,2320],{"class":797},"`",[107,2322,2323],{"class":120},"${",[107,2325,2326],{"class":325},"asset",[107,2328,885],{"class":120},[107,2330,2331],{"class":325},"id",[107,2333,2334],{"class":120},"}",[107,2336,2337],{"class":797}," IN (SELECT asset_id FROM asset_fts WHERE name MATCH ",[107,2339,2323],{"class":120},[107,2341,2342],{"class":113},"escapeFtsMatch",[107,2344,130],{"class":120},[107,2346,2294],{"class":325},[107,2348,2307],{"class":120},[107,2350,2334],{"class":120},[107,2352,2353],{"class":797},")`",[107,2355,891],{"class":120},[107,2357,2358],{"class":109,"line":143},[107,2359,176],{"class":120},[107,2361,2362,2365,2368,2370,2372,2374,2376,2378,2381,2383,2385,2387,2390],{"class":109,"line":159},[107,2363,2364],{"class":113},"return",[107,2366,2367],{"class":113}," like",[107,2369,130],{"class":120},[107,2371,2326],{"class":325},[107,2373,885],{"class":120},[107,2375,2247],{"class":325},[107,2377,360],{"class":120},[107,2379,2380],{"class":797}," `%",[107,2382,2323],{"class":120},[107,2384,2294],{"class":325},[107,2386,2334],{"class":120},[107,2388,2389],{"class":797},"%`",[107,2391,2392],{"class":120},");\n",[14,2394,2395,2396,2398,2399,2401,2402,2405,2406,2409],{},"The subquery is non-correlated, so ",[62,2397,2272],{}," runs once and the planner uses the FTS index instead of materialising an id list into the outer query. The index tracks the ",[62,2400,2326],{}," table through SQL triggers, and the update trigger is scoped ",[62,2403,2404],{},"AFTER UPDATE OF name"," on purpose — the constant view-count and download-count writes never touch it. None of this fits Drizzle (it can't model a virtual table or a trigger), so the DDL is idempotent ",[62,2407,2408],{},"IF NOT EXISTS"," and four paths apply it and agree.",[14,2411,2412,2414,2415,183,2418,183,2421,2424,2425,183,2428,2431,2432,2434],{},[62,2413,2272],{}," has its own query grammar (",[62,2416,2417],{},"AND",[62,2419,2420],{},"OR",[62,2422,2423],{},"NEAR",", prefix ",[62,2426,2427],{},"*",[62,2429,2430],{},"-"," negation), so raw user input would let someone inject an operator or crash on a stray ",[62,2433,2427],{},". The escape wraps the whole term in double quotes and doubles any embedded quote, forcing FTS5 to read every character as a literal:",[73,2436,2438],{"className":2279,"code":2437,"language":2281,"meta":81,"style":81},"function escapeFtsMatch(term: string): string {\n  return '\"' + term.replace(\u002F\"\u002Fg, '\"\"') + '\"';\n}\n",[62,2439,2440,2464,2503],{"__ignoreMap":81},[107,2441,2442,2445,2448,2450,2452,2454,2457,2460,2462],{"class":109,"line":110},[107,2443,2444],{"class":113},"function",[107,2446,2447],{"class":113}," escapeFtsMatch",[107,2449,130],{"class":120},[107,2451,2294],{"class":325},[107,2453,329],{"class":120},[107,2455,2456],{"class":113}," string",[107,2458,2459],{"class":120},"):",[107,2461,2456],{"class":113},[107,2463,121],{"class":120},[107,2465,2466,2468,2471,2474,2477,2479,2482,2484,2487,2490,2492,2495,2497,2499,2501],{"class":109,"line":124},[107,2467,2314],{"class":113},[107,2469,2470],{"class":797}," '\"'",[107,2472,2473],{"class":120}," +",[107,2475,2476],{"class":325}," term",[107,2478,885],{"class":120},[107,2480,2481],{"class":113},"replace",[107,2483,130],{"class":120},[107,2485,2486],{"class":797},"\u002F\"\u002F",[107,2488,2489],{"class":113},"g",[107,2491,360],{"class":120},[107,2493,2494],{"class":797}," '\"\"'",[107,2496,2307],{"class":120},[107,2498,2473],{"class":120},[107,2500,2470],{"class":797},[107,2502,891],{"class":120},[107,2504,2505],{"class":109,"line":143},[107,2506,176],{"class":120},[557,2508,2510],{"id":2509},"semantic-search-two-lanes-blended","Semantic search: two lanes, blended",[14,2512,2513],{},"The reason this exists is a Discord channel. My server has one called #asset-hunt, and it got used a lot: does anybody know an asset with xyz, where do I get this from, half of it asked in the wrong channel anyway. The site kept growing and people kept asking for tags, and I didn't want anyone sitting there tagging tens of thousands of images by hand, me least of all. I wanted it automated, and I wanted \"hey, does anyone know\" to be a question the database could answer itself. When the monorepo rewrite happened, I decided that was the thing I was going to solve.",[14,2515,2516,2517,2520],{},"Typing a filename and typing a ",[818,2518,2519],{},"description"," of a picture are different searches, and the second gets answered by two indexes at once.",[581,2522,2524],{"caption":2523},"A description query fans into both lanes. A written word is precision, a cosine is recall, and when both turn up the same asset the blend keeps the semantic score rather than inventing one.",[2063,2525],{":columns":2526,":edges":2527},"[[{\"id\":\"query\",\"title\":\"\\\"blue hair, red jacket\\\"\",\"sub\":\"a description\"}],[{\"id\":\"captions\",\"title\":\"caption index\",\"sub\":\"fts5 porter\"},{\"id\":\"vectors\",\"title\":\"vector index\",\"sub\":\"siglip2 cosine\"}],[{\"id\":\"blend\",\"title\":\"blended results\",\"sub\":\"captions first, vectors behind\",\"em\":true}]]","[{\"from\":\"query\",\"to\":\"captions\",\"label\":\"match\"},{\"from\":\"query\",\"to\":\"vectors\",\"label\":\"embed\"},{\"from\":\"captions\",\"to\":\"blend\",\"label\":\"precision\"},{\"from\":\"vectors\",\"to\":\"blend\",\"label\":\"recall\"}]",[14,2529,2530],{},"The caption lane is a second FTS5 table, this one over captions and tags a vision model wrote for every asset, tokenized with porter instead of trigram because a caption is an English sentence where \"sword\" should match \"swords\". The semantic lane is cosine similarity over image embeddings: the query is embedded into the same vector space the pictures live in, and the nearest vectors come back ranked.",[14,2532,2533],{},"Neither lane is much good alone, and the reason is the shape of what each stores. An embedding is dense and approximate — every asset scores against every query, so there's always a full ranked list and the bottom of it is noise with a number attached. A caption index is sparse and exact — a word is either written on that asset or it isn't, and when it is, that's a much harder fact than a cosine of 0.31. So caption hits rank first and the semantic lane fills in behind, catching the phrasings nobody's caption happened to use. Captions also carry the entire weight for the half of the catalogue whose filenames are machine ids — hashes and export numbers from whatever tool ripped them. No tokenizer saves you there; the caption index is the only place real words exist for those assets.",[557,2535,2537],{"id":2536},"a-vector-is-only-comparable-to-itself","A vector is only comparable to itself",[14,2539,2540,2541,2544],{},"This is the part that's easy to get wrong. A vector means nothing on its own. It's only meaningful next to vectors from the ",[818,2542,2543],{},"same model, at the same precision, normalised the same way",". Change any of that and you haven't made retrieval slightly worse — you've started comparing two different spaces.",[14,2546,2547],{},"Backfilling the initial 43,000 took genuinely forever, and the first pass ran partly on my own 5070 at home, quantised down to fit, because I wasn't going to pay for a full-precision run before knowing whether anyone would even use the thing. The telemetry answered that fast: people leaned on it far harder than I expected, so I went all out.",[14,2549,2550,2551,2554],{},"The catalogue is embedded with ",[62,2552,2553],{},"siglip2-base-patch16-512"," at fp16, and the fp16 matters as much as the model id. A q8-quantised build of the same model, measured against fp16 on the same images, lands anywhere from cosine 0.70 to 0.93. Nothing errors on a number like that: still 768 floats, search still returns a full page, and the results are quietly wrong in the only way you ever find out about — searching for something you know is in there and not seeing it.",[581,2556,2558],{"caption":2557},"Cosine against the fp16 reference on the same images. The q8 build isn't a lossy version of the same space, it's a different one. The third bar is the endpoint's parity check: embed an image that already has a catalogue row, expect its own vector back.",[585,2559],{":data":2560,":max":2561},"[{\"label\":\"q8, worst case\",\"sublabel\":\"vs fp16\",\"value\":0.7,\"display\":\"0.70\"},{\"label\":\"q8, best case\",\"sublabel\":\"vs fp16\",\"value\":0.93,\"display\":\"0.93\"},{\"label\":\"fp16 endpoint\",\"sublabel\":\"vs the live row\",\"value\":1,\"display\":\"1.0000\",\"em\":true}]","1",[14,2563,2564,2565,2568],{},"So parity is a rule, not a preference. The GPU worker that embeds new uploads runs the same model id, dtype, and normalisation as the run that did the catalogue, and its smoke test embeds an image that already has a database row and asserts the cosine against that row is ",[62,2566,2567],{},"1.0000"," exactly — not a tolerance, because anything short of the same vector means the two sides disagree about something.",[557,2570,2572],{"id":2571},"the-in-process-vector-index","The in-process vector index",[14,2574,2575],{},"The obvious place to run the similarity scan is SQL, and it holds up right until two people search at once. Every query drags ~120MB of vectors through the engine and the scans queue behind each other. p50 looked healthy at ~48ms; production p99 was 19.5 seconds, with a 43-second worst case in the traces.",[14,2577,2578,2579,2582],{},"But 120MB is 120MB — the whole set fits in memory. So the scan moved out of the database into a row-major ",[62,2580,2581],{},"Float32Array"," holding every vector, normalised once at load, and a search is a brute-force dot product across it. No approximate-nearest-neighbour structure, no vector service, no index tuning, just arithmetic over a contiguous buffer, which is a thing CPUs are extremely good at.",[581,2584,2586],{"caption":2585},"Semantic search p99, scanning vectors in SQL versus in memory. Same vectors, same catalogue, brute force in both cases.",[2587,2588],"bar-chart",{":data":2589,"format":2590},"[{\"label\":\"vector scan in SQL\",\"value\":19500},{\"label\":\"in-process index\",\"value\":400,\"em\":true}]","ms-s",[14,2592,2593,2594,2599],{},"Freshness is the only interesting part. Embeddings change rarely, so searches serve the current snapshot immediately and never block on being up to date. A background probe reads a handful of counts into a fingerprint at most once every 15 seconds; a fingerprint that moved kicks off a rebuild, also in the background. A new row is live in search ~15 seconds after it's written and nobody's query ever waits on a rebuild. Same instinct as not running ",[24,2595,2598],{"href":2596,"rel":2597},"https:\u002F\u002Fwww.meilisearch.com",[28],"Meilisearch",": the data already fits somewhere I control, so it doesn't need a process of its own.",[38,2601,2603],{"id":2602},"enrichment-push-for-latency-pull-for-correctness","Enrichment: Push for Latency, Pull for Correctness",[14,2605,2606,2607,2612],{},"Both search lanes read derived state — a vector and a caption are functions of the image and the model, and every asset needs them computed exactly once. That work wants a GPU, and skowt owns the GPU rather than renting an API in front of someone else's: one Docker image with both models, deployed as a ",[24,2608,2611],{"href":2609,"rel":2610},"https:\u002F\u002Frunpod.io",[28],"RunPod"," serverless endpoint on 4090s that scales to zero and bills per second, so it costs nothing between uploads.",[14,2614,2615],{},"The design is one phrase, and each half is bad at exactly what the other is good at.",[581,2617,2619],{"caption":2618},"Two producers, one queue, one definition of done. The queue exists for latency, the sweep for truth. Neither knows the other is there, because a missing row is the to-do item and a written row is the receipt.",[2063,2620],{":columns":2621,":edges":2622},"[[{\"id\":\"commit\",\"title\":\"upload commit\",\"sub\":\"the fast lane\"},{\"id\":\"sweep\",\"title\":\"5-minute sweep\",\"sub\":\"reconciliation\"}],[{\"id\":\"queue\",\"title\":\"bullmq queue\",\"sub\":\"jobId = asset id\"}],[{\"id\":\"gpu\",\"title\":\"gpu endpoint\",\"sub\":\"runpod, per second\"}],[{\"id\":\"rows\",\"title\":\"derived rows\",\"sub\":\"vector + caption\"}]]","[{\"from\":\"commit\",\"to\":\"queue\",\"label\":\"enqueue\"},{\"from\":\"sweep\",\"to\":\"queue\"},{\"from\":\"queue\",\"to\":\"gpu\"},{\"from\":\"gpu\",\"to\":\"rows\"},{\"from\":\"rows\",\"to\":\"sweep\",\"label\":\"a missing row is the to-do\",\"dashed\":true}]",[14,2624,2625,2626,2630,2631,2634],{},"The ",[2627,2628,2629],"strong",{},"pull"," side is a reconciliation sweep, and it works because pending work is definable as a query: an asset needs a vector if there's no row for it under the active model tag. That's the entire definition. There's no job record to keep in step with reality and no dead-letter queue to drain, because the ",[818,2632,2633],{},"absence of the row is the to-do item",". A job that dies halfway leaves the row missing, so the next sweep picks it up with no idea anything went wrong. Flushing Redis throws away every queued job and costs a few minutes. There's no state to repair after any of it, because the only state is the data.",[14,2636,2625,2637,2640,2641,2643,2644,2647],{},[2627,2638,2639],{},"push"," side is a BullMQ queue on the same Redis, because a sweep can't be fast — worst case an upload waits the full interval. ",[62,2642,2118],{}," enqueues the asset id, a concurrency-2 worker calls the endpoint and writes both marks, and the FTS triggers plus the vector index's fingerprint probe make the asset searchable seconds later. ",[62,2645,2646],{},"jobId"," is the asset id, so enqueueing something already in flight is a no-op and both producers share the queue without coordinating. Jobs retry three times and then drop on purpose, because retrying past that is just a worse implementation of the sweep, and the sweep owns the long tail. Losing the entire queue costs latency and never data — the only reason I was willing to put a queue in at all.",[581,2649,2651],{"caption":2650},"Upload to findable. The sweep alone is a five-minute worst case; the queue puts it in the same order of magnitude as the upload itself. Both paths write identical rows.",[2587,2652],{":data":2653,"format":2654},"[{\"label\":\"reconciliation sweep alone\",\"value\":300},{\"label\":\"fast lane\",\"value\":6,\"em\":true}]","s",[14,2656,2657,2658,2661,2662,2665],{},"Both stores carry a ",[62,2659,2660],{},"model"," column and every row is written with the tag that produced it. Nothing is ever rewritten in place, which makes a model upgrade dull: backfill the new tag next to the old rows, flip one constant in ",[62,2663,2664],{},"packages\u002Fapi",", drop the old rows whenever convenient. If the new space turns out worse, the constant flips back and the old vectors are still sitting there.",[38,2667,2669],{"id":2668},"the-cheap-stuff-that-keeps-it-cheap","The Cheap Stuff That Keeps It Cheap",[14,2671,2672,2673,2676],{},"The catalogue query is the hot path — it runs on every game page, filter, sort, and scroll. Two things carry it: a Redis cache keyed by the full parameter set (with a bounded in-memory fallback if Redis is unreachable), and keyset pagination instead of ",[62,2674,2675],{},"LIMIT\u002FOFFSET",". Keyset pins an exact boundary row and resumes past it with a tuple comparison, so deep pages don't get linearly slower and an insert between page loads can't hand you a duplicate or a gap.",[14,2678,2679,2680,2685,2686,2689],{},"Sessions run through Redis too. Turso sits across the network, so a session check that reads the session row and then the user row is two round-trips at ~57ms each — paid on every authenticated request. ",[24,2681,2684],{"href":2682,"rel":2683},"https:\u002F\u002Fwww.better-auth.com",[28],"better-auth"," takes a secondary storage adapter, so that collapses to one sub-millisecond ",[62,2687,2688],{},"GET",", and it's not a dumb TTL cache: it invalidates on sign-out and revocation, and a permission change patches the live cached session rather than waiting for a re-login.",[581,2691,2693],{"caption":2692},"One session check, before and after: two Turso round-trips versus one Redis GET.",[2587,2694],{":data":2695},"[{\"label\":\"turso: session row, then user row\",\"value\":114,\"display\":\"114 ms\"},{\"label\":\"redis: one GET\",\"value\":1,\"display\":\"\u003C1 ms\",\"em\":true}]",[14,2697,2698],{},"Rate limiting is a real sliding window, not a fixed bucket: each window is a sorted set scored by millisecond timestamp, and the trim-count-admit runs as one atomic Lua script so two concurrent requests can't both read a count under the limit and both get admitted.",[14,2700,2701,2702,2707,2708,2713],{},"Telemetry is the same flavour of cheap. Every request emits exactly one structured wide event at response time, built up in place as it moves through Elysia's hooks and shipped to ",[24,2703,2706],{"href":2704,"rel":2705},"https:\u002F\u002Fbetterstack.com",[28],"Better Stack"," with the standard ",[24,2709,2712],{"href":2710,"rel":2711},"https:\u002F\u002Fopentelemetry.io",[28],"OTel"," HTTP attributes next to cheap domain identity: the user id, which procedures ran, how many database queries the request made. That turns the log explorer into one query surface: \"every request where this user hit a procedure that ran more than 50 database queries\" is a single filter, no trace to open, and a query count over 50 flags an N+1 on its own.",[14,2715,2716],{},"That instrumentation earned its keep the morning the web service sat at ~2GB of memory. I was on my phone, asked the Railway agent in Discord what was going on, and it came back with: you have a memory leak, and your memory has climbed since this commit. The commit was SEO work — SSR plus a sitemap advertising ~39,000 asset URLs — and it had built the QueryClient at module scope, so crawlers walked the sitemap and every unique URL added an entry to a render cache nobody ever read. The fix builds those objects per-request instead. The leak and the invitations went out in the same commit.",[38,2718,2720],{"id":2719},"where-it-sits","Where It Sits",[14,2722,2723],{},"As of writing, that's ~43,000 assets across 23 games, 27 million-plus downloads, 190 million-plus views, run by one person plus the Discord regulars I trust not to get phished. Expanding past images is the next pressure the design has to absorb — but the presigned path already doesn't care what the bytes are, the edge worker already refuses to serve anything it doesn't recognise, and the MIME map is one entry away from learning a new type.",[1204,2725,2726],{},"html pre.shiki code .sH9Dd, html code.shiki .sH9Dd{--shiki-default:var(--color-text)}html pre.shiki code .s_Mgl, html code.shiki .s_Mgl{--shiki-default:var(--color-body)}html pre.shiki code .svQrX, html code.shiki .svQrX{--shiki-default:var(--color-muted)}html pre.shiki code .sRQ_c, html code.shiki .sRQ_c{--shiki-default:var(--code-string)}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sneuw, html code.shiki .sneuw{--shiki-default:var(--code-literal)}",{"title":81,"searchDepth":124,"depth":124,"links":2728},[2729,2730,2731,2732,2738,2739,2740],{"id":2015,"depth":124,"text":2016},{"id":2096,"depth":124,"text":2097},{"id":2129,"depth":124,"text":2130},{"id":2181,"depth":124,"text":2182,"children":2733},[2734,2735,2736,2737],{"id":2192,"depth":143,"text":2193},{"id":2509,"depth":143,"text":2510},{"id":2536,"depth":143,"text":2537},{"id":2571,"depth":143,"text":2572},{"id":2602,"depth":124,"text":2603},{"id":2668,"depth":124,"text":2669},{"id":2719,"depth":124,"text":2720},"skowt.cc is an asset database for game art. Contributors upload assets, a lot of them from gacha games, and other people pull them back down, often a few hundred at once. Traffic is spiky and downloads arrive in batches, so the whole thing runs on one bias: keep the request path cheap, and never make the API do work it can hand to something else.",{},"\u002Fposts\u002Fskowt-cc-architecture",{"title":1994,"description":2741},"posts\u002Fskowt-cc-architecture","The architecture of how skowt.cc is built, including its newest search feature.","ZaZ8u1Evjp61vLojIXViQUCXeGDlu9ECvrlDZZiJ43w",{"id":2749,"title":2750,"body":2751,"description":2755,"extension":1227,"meta":4395,"navigation":741,"order":173,"path":4396,"seo":4397,"stem":4398,"summary":4399,"visibility":1234,"__hash__":4400},"posts\u002Fposts\u002Fxb360-exploit-chain.md","li r3, 1: An Xbox 360 Exploit Chain",{"type":8,"value":2752,"toc":4376},[2753,2756,2759,2762,2769,2773,2787,2790,2794,2836,2852,2856,2859,2862,2880,2889,2893,2917,2924,2938,2942,2985,3012,3015,3035,3039,3042,3048,3051,3182,3192,3204,3208,3216,3224,3260,3263,3288,3301,3371,3401,3404,3419,3433,3452,3455,3459,3498,3578,3596,3599,3603,3606,3610,3678,3682,3739,3795,3799,3802,3847,3864,3895,3898,3901,4005,4008,4039,4042,4127,4130,4195,4199,4249,4267,4283,4287,4335,4339,4342,4345,4370,4373],[14,2754,2755],{},"I loved Halo Reach growing up. It defined an entire era of my childhood in a way that no other game has since - nothing beats 10-year-old me screaming down a one-ear headset at teammates 2-3x my age. So when a friend gave me a Halo Reach Limited Edition Xbox 360 S, I knew I had to do something with it.",[14,2757,2758],{},"The console came with a 250GB HDD and dashboard version 2.0.17526.0. I planned to RGH3 (Reset Glitch Hack 3 - a hardware mod that glitches the CPU during boot to bypass signature checks) it later, but ended up exploiting it the same day with BadUpdate - a software-only hypervisor exploit requiring zero soldering and zero NAND (the flash memory chip that stores the console's firmware and bootloaders) modification. BadUpdate achieves unsigned code execution through a race condition against the hypervisor's LZX (Lempel-Ziv extended, Microsoft's compression algorithm) decompressor, using an encrypted memory side channel as a timing oracle.",[14,2760,2761],{},"The full chain runs from a stack overflow in an avatar name field to hypervisor shellcode, then through the patches XeUnshackle applies to the security chain.",[14,2763,2764],{},[2765,2766],"img",{"alt":2767,"src":2768},"The BadUpdate exploit chain as a six-stage vertical flow. One, a crafted Xbox 360 avatar name overflows a stack buffer at the sign-in screen. Two, the overflow overwrites saved registers and the link register, redirecting execution into a stack-pivot gadget. Three, a ROP chain builds a ciphertext oracle, caching 1024 whitening values in a lookup table. Four, two threads race the LZX decompressor: one reads ciphertext while the other hammers HvxKeysExecute. Five, the race swaps the decompressor's dec_output_buffer pointer so the write lands inside the hypervisor segment. Six, XeUnshackle patches the hypervisor and kernel, giving unsigned code execution.","\u002Fdiagrams\u002Fxb360-chain.svg",[38,2770,2772],{"id":2771},"trinity","Trinity",[14,2774,2775,2776,2782,2783,885],{},"The Xbox 360 went through seven motherboard revisions over its lifespan - Xenon, Zephyr, Falcon, Jasper, Trinity, Corona and Winchester - where Xenon through Jasper are the original \"phat\" chassis and Trinity was the first slim board, the 360 S redesign that shipped in 2010 with a smaller chassis, integrated WiFi and a single centrifugal blower replacing the dual-fan setup. You can identify the board revision in code via ",[89,2777,2779],{"as":2778},"Xbox Hardware Info, Flags bitwise-AND hex F followed by seven zeroes",[62,2780,2781],{},"XboxHardwareInfo-&gt;Flags &amp; 0xF0000000",", where Trinity returns ",[89,2784,2785],{"as":1632},[62,2786,1635],{},[14,2788,2789],{},"The Reach Limited Edition is a Trinity; Trinity has stable glitch timing for RGH3 and falls inside the dashboard range BadUpdate targets. The console still had old user data on it, so I formatted the drive and created an offline profile from the Guide menu since it was greyed out on the main dashboard.",[38,2791,2793],{"id":2792},"dashboard-17559","Dashboard 17559",[14,2795,2796,2797,2803,2804,2810,2811,183,2817,2810,2823,190,2829,2835],{},"BadUpdate works on dashboard version 2.0.17559.0, an Xbox 360 system update released November 2019; the version is load-bearing since each address in the exploit is hardcoded for this exact kernel build, including the ROP gadgets, syscall ordinals, function offsets and patch locations. ",[89,2798,2800],{"as":2799},"Kernel Config Retail 17559 dot asm",[62,2801,2802],{},"KernelConfig_Retail_17559.asm"," in the BadUpdate source is a 270-line file of nothing but hardcoded addresses like ",[89,2805,2807],{"as":2806},"stack pivot",[62,2808,2809],{},"stack_pivot"," at ",[89,2812,2814],{"as":2813},"hex 8 1 7 2 5 3 7 8",[62,2815,2816],{},"0x81725378",[89,2818,2820],{"as":2819},"call func preload",[62,2821,2822],{},"call_func_preload",[89,2824,2826],{"as":2825},"hex 8 1 6 9 C D D C",[62,2827,2828],{},"0x8169CDDC",[89,2830,2832],{"as":2831},"X Physical Alloc",[62,2833,2834],{},"XPhysicalAlloc"," at the game-specific address.",[14,2837,2838,2839,2844,2845,2851],{},"My console was on 17526, so I grabbed the ",[24,2840,2843],{"href":2841,"rel":2842},"https:\u002F\u002Farchive.org\u002Fdetails\u002Fxbox-360-system-update-17559-usb",[28],"17559 USB update from archive.org",", put the ",[89,2846,2848],{"as":2847},"dollar-sign System Update",[62,2849,2850],{},"$SystemUpdate"," folder on a FAT32 stick and let the console pick it up on boot.",[38,2853,2855],{"id":2854},"badupdate","BadUpdate",[14,2857,2858],{},"BadUpdate is a software-only hypervisor exploit targeting dashboard 17559, written by Grimdoomer, requiring no soldering, no NAND dumping and no hardware mods; you plug in a USB stick, boot the console and get unsigned code execution.",[14,2860,2861],{},"The same exploit has multiple entry points:",[2863,2864,2865],"ul",{},[2866,2867,2868,2871,2872,2875,2876,2879],"li",{},[2627,2869,2870],{},"BadUpdate (original)"," - requires owning Tony Hawk's American Wasteland or Rock Band Blitz, where a crafted save file triggers a stack buffer overflow when loaded in-game. - ",[2627,2873,2874],{},"ABadAvatar"," by shutterbug2000 - requires no game at all, since a crafted Xbox 360 avatar profile triggers a buffer overflow in the dashboard's avatar rendering pipeline from the sign-in screen. - ",[2627,2877,2878],{},"ABadMemUnit \u002F ABadAvatarHDD"," - the same avatar exploit targeting different storage devices.",[14,2881,2882,2883,2888],{},"I went with ABadAvatar since I didn't own either supported game; I used ",[24,2884,2887],{"href":2885,"rel":2886},"https:\u002F\u002Fgithub.com\u002FLxcyDr0p\u002FBadStick",[28],"BadStick"," to automate the USB setup - a C# WinForms tool that formats the drive, downloads the exploit packages and homebrew from GitHub releases and extracts everything into the correct directory layout, serving as a provisioning tool rather than an exploit itself.",[557,2890,2892],{"id":2891},"encrypted-memory-and-the-lzx-race","Encrypted Memory and the LZX Race",[14,2894,2895,2896,2902,2903,2909,2910,2913,2914,885],{},"The Xbox 360's hypervisor provides a syscall called ",[89,2897,2899],{"as":2898},"H V X Keys Execute",[62,2900,2901],{},"HvxKeysExecute"," (ordinal ",[89,2904,2906],{"as":2905},"hex 4 2",[62,2907,2908],{},"0x42",") that processes XKE (Xbox Kernel Extension) update payloads by taking a buffer of LZX-compressed data and decompressing it; this decompression happens in ",[2627,2911,2912],{},"encrypted memory"," - memory that the CPU encrypts and decrypts via a transparent hardware encryption engine, where the hypervisor sees plaintext but usermode code with access to the physical backing pages can observe the ",[2627,2915,2916],{},"ciphertext",[14,2918,2919,2920,2923],{},"The encryption uses per-page \"whitening\" values that rotate through 1024 possible slots, so if you know what plaintext the hypervisor is about to write and you can observe the corresponding ciphertext, you can predict the ciphertext for ",[818,2921,2922],{},"any"," plaintext at that whitening value; you've built an oracle.",[14,2925,2926,2927,2933,2934,2937],{},"The LZX decompressor maintains internal state including a pointer called ",[89,2928,2930],{"as":2929},"dec output buffer",[62,2931,2932],{},"dec_output_buffer"," that controls where decompressed data gets written; that pointer lives in the same encrypted memory region. If you can replace the ciphertext for that pointer with ciphertext that decrypts to an address ",[818,2935,2936],{},"you"," control - say, a location inside the hypervisor's own code pages - the decompressor writes its output into the hypervisor. The whole exploit is a race condition against the decompressor, using an encrypted memory side channel as the timing oracle.",[557,2939,2941],{"id":2940},"stack-overflow-to-rop","Stack Overflow to ROP",[14,2943,2944,2945,2951,2952,2958,2959,1730,2964,2969,2970,2974,2975,2979,2980,2984],{},"For the original BadUpdate, the entry point is a stack buffer overflow in Tony Hawk's American Wasteland's gap name parser, where the save file places crafted data at file offset ",[89,2946,2948],{"as":2947},"hex D F 4",[62,2949,2950],{},"0xDF4"," which lands on the heap at ",[89,2953,2955],{"as":2954},"hex B 4 3 B 6 8 2 E",[62,2956,2957],{},"0xB43B682E","; the crafted data overflows the gap name buffer with 60 bytes of padding followed by controlled register values for ",[89,2960,2962],{"as":2961},"r23",[62,2963,2961],{},[107,2965,2967],{"as":2966},"r31",[62,2968,2966],{}," and a link register value pointing to a ",[89,2971,2972],{"as":2806},[62,2973,2809],{}," gadget at ",[89,2976,2977],{"as":2813},[62,2978,2816],{}," in ",[89,2981,2983],{"as":2982},"xam dot x-e-x","xam.xex"," (the Xbox Auxiliary Module, the dashboard's core system library):",[73,2986,2990],{"className":2987,"code":2988,"language":2989,"meta":81,"style":81},"language-asm shiki shiki-themes dromzeh","lwz  r1, 0(r1)    ; redirect the stack pointer to attacker-controlled data\nlwz  r12, -8(r1)\nmtlr r12\nblr\n","asm",[62,2991,2992,2997,3002,3007],{"__ignoreMap":81},[107,2993,2994],{"class":109,"line":110},[107,2995,2996],{},"lwz  r1, 0(r1)    ; redirect the stack pointer to attacker-controlled data\n",[107,2998,2999],{"class":109,"line":124},[107,3000,3001],{},"lwz  r12, -8(r1)\n",[107,3003,3004],{"class":109,"line":143},[107,3005,3006],{},"mtlr r12\n",[107,3008,3009],{"class":109,"line":159},[107,3010,3011],{},"blr\n",[14,3013,3014],{},"This pivots execution into the ROP (Return-Oriented Programming - chaining together existing code fragments via their return instructions) chain; control of the instruction stream is now in attacker hands.",[14,3016,3017,3018,3020,3021,3027,3028,3034],{},"For ABadAvatar, which is the route I took, the entry point differs but the mechanism is identical; instead of a save file, a malformed avatar item whose name field overflows a fixed-size stack buffer during avatar rendering overwrites the same saved registers and link register, hits the same ",[62,3019,2809],{}," gadget and enters the same ROP pipeline. The avatar data lands at heap address ",[89,3022,3024],{"as":3023},"hex 4 3 A B 9 A C 8",[62,3025,3026],{},"0x43AB9AC8",", gets relocated to ",[89,3029,3031],{"as":3030},"hex 4 3 6 7 followed by four zeroes",[62,3032,3033],{},"0x43670000"," from what the source comments call a \"comically large allocation\" by the avatar system; the oracle builder loads from there. ABadAvatar fires from the dashboard's profile selection screen the moment the console renders the crafted avatar, requiring no game disc and no user interaction.",[557,3036,3038],{"id":3037},"building-the-ciphertext-oracle","Building the Ciphertext Oracle",[14,3040,3041],{},"The oracle builder constructs a lookup table mapping encrypted memory values to their replacements, so the race attack can swap a pointer in the decompressor's state before it reads it.",[14,3043,3044,3045,3047],{},"The oracle builder is a long ROP chain, where each operation chains together existing code fragments in ",[89,3046,2983],{"as":2982}," and the kernel, using the stack to control execution flow; there's no injected code yet because the exploit hasn't achieved that capability, so it's using the console's own code against itself.",[14,3049,3050],{},"The ROP chain builds the ciphertext oracle needed for the race attack through a three-step process:",[3052,3053,3054,3080,3176],"ol",{},[2866,3055,3056,3065,3066,3072,3073,3079],{},[2627,3057,3058,3059],{},"Load ",[89,3060,3062],{"as":3061},"boot anim dot xex",[62,3063,3064],{},"bootanim.xex"," (the boot animation) via ",[89,3067,3069],{"as":3068},"Xex Load Image",[62,3070,3071],{},"XexLoadImage",", whose code pages land at a predictable virtual address (",[89,3074,3076],{"as":3075},"hex 9 0 1 1 followed by four zeroes",[62,3077,3078],{},"0x90110000","); capture 16 bytes of known plaintext from this address.",[2866,3081,3082,3085,3086,3092,3093,1675,3099,3105,3106,3112,3113,3119,3120,3122,3123,3126,3127,3131,3132,3138,3139,3142,3143,3149,3150,2291,3156,3162,3163,3169,3170,885],{},[2627,3083,3084],{},"Exhaust all 1024 whitening values"," by allocating and freeing encrypted memory regions in a loop at virtual address ",[89,3087,3089],{"as":3088},"hex 8 D followed by six zeroes",[62,3090,3091],{},"0x8D000000"," via ",[89,3094,3096],{"as":3095},"H V X Encrypted Reserve Allocation",[62,3097,3098],{},"HvxEncryptedReserveAllocation",[107,3100,3102],{"as":3101},"H V X Encrypted Release Allocation",[62,3103,3104],{},"HvxEncryptedReleaseAllocation",". Each cycle increments the whitening counter and captures two ciphertexts: the LZX decoder's context header (signature ",[89,3107,3109],{"as":3108},"C I D L",[62,3110,3111],{},"'CIDL'",", window size ",[89,3114,3116],{"as":3115},"hex 8 triple-zero",[62,3117,3118],{},"0x8000",", CPU type ",[62,3121,2561],{},") - the ",[2627,3124,3125],{},"canary"," - and a malicious ",[89,3128,3129],{"as":2929},[62,3130,2932],{}," pointer targeting ",[89,3133,3135],{"as":3134},"hex 8 triple-zero 0 1 0 6 dot triple-zero 3 0 9 4 0",[62,3136,3137],{},"0x80000106.00030940"," inside the hypervisor's last segment - the ",[2627,3140,3141],{},"replacement",". That target is the base at ",[89,3144,3146],{"as":3145},"hex 8 triple-zero 0 1 0 6 dot triple-zero 3 followed by four zeroes",[62,3147,3148],{},"0x80000106.00030000"," plus ",[89,3151,3153],{"as":3152},"hex 9 4 0",[62,3154,3155],{},"0x940",[89,3157,3159],{"as":3158},"H V seg 3 overwrite offset minus block 14 target offset",[62,3160,3161],{},"HV_SEG3_OVERWRITE_OFFSET - BLOCK_14_TARGET_OFFSET","), so that block 14's decompressed data starting at internal offset ",[89,3164,3166],{"as":3165},"hex 1 5 E 8",[62,3167,3168],{},"0x15E8"," lands at HV offset ",[89,3171,3173],{"as":3172},"hex 1 F 2 8",[62,3174,3175],{},"0x1F28",[2866,3177,3178,3181],{},[2627,3179,3180],{},"Store everything in a lookup table"," indexed by the top 10 bits of the ciphertext, giving 1024 slots for canaries and 1024 slots for replacements.",[14,3183,3184,3185,3191],{},"Capturing the ciphertext for a known plaintext relies on the encrypted allocation's dual mapping: the physical page behind the encrypted virtual address is also reachable through an unencrypted alias. The exploit writes its plaintext through the encrypted address, flushes the cache with ",[89,3186,3188],{"as":3187},"Ke Flush Cache Range",[62,3189,3190],{},"KeFlushCacheRange",", then reads the alias back with an ordinary memcpy - that read is the raw ciphertext of whatever it just wrote. Same bytes, plaintext through one mapping and ciphertext through the other.",[14,3193,3194,3195,3198,3199,3203],{},"The ROP chain runs in a ",[2627,3196,3197],{},"dual-buffer bounce loop"," to handle its indefinite runtime, where each iteration copies its gadget data to an alternate buffer and stack-pivots to it so the chain can keep loading and unloading ",[89,3200,3201],{"as":3061},[62,3202,3064],{}," until the whitening value matches without overwriting itself across iterations.",[557,3205,3207],{"id":3206},"racing-the-decompressor","Racing the Decompressor",[14,3209,3210,3211,3215],{},"With the lookup table built, the exploit launches two threads: one hammers ",[89,3212,3213],{"as":2898},[62,3214,2901],{}," to trigger decompression, the other watches the ciphertext and swaps the decompressor's output pointer the moment the timing is right, redirecting decompressed data into hypervisor memory.",[14,3217,3218,3219,3223],{},"The race itself is actual PPC (PowerPC, the Xbox 360's CPU architecture) machine code rather than ROP; since GCC couldn't produce working output for this target, the author hand-assembled it. It runs at ",[89,3220,3221],{"as":3075},[62,3222,3078],{}," in the boot animation's memory space, overwritten with the race payload using the ciphertext captured by the oracle builder.",[14,3225,3226,3227,3233,3234,3240,3241,2810,3247,3253,3254,885],{},"Before starting the race, the code calls ",[89,3228,3230],{"as":3229},"Ke Lock L2",[62,3231,3232],{},"KeLockL2"," twice to lock 256KB of L2 cache each time with trash data, forcing the encrypted memory's ciphertext to evict to main memory faster where the monitoring thread can observe changes. It also writes ",[89,3235,3237],{"as":3236},"hex 6 6 6 6 6 6 6 6",[62,3238,3239],{},"0x66666666"," to ",[89,3242,3244],{"as":3243},"Mm Physical 64 K B Mapping Table",[62,3245,3246],{},"MmPhysical64KBMappingTable",[89,3248,3250],{"as":3249},"hex 8 0 1 C 1 triple-zero",[62,3251,3252],{},"0x801C1000"," to make the hypervisor's encrypted segments observable at virtual address ",[89,3255,3257],{"as":3256},"hex A followed by seven zeroes",[62,3258,3259],{},"0xA0000000",[14,3261,3262],{},"Then it launches two threads on separate hardware cores:",[14,3264,3265,3268,3269,3273,3274,3280,3281,3287],{},[2627,3266,3267],{},"Thread 1"," (hardware thread 1) runs ",[89,3270,3271],{"as":2898},[62,3272,2901],{}," in a tight loop, copying clean payload data, calling the syscall and checking if the ciphertext at the target hypervisor location changed; each failed attempt returns ",[89,3275,3277],{"as":3276},"hex C 8 four zeroes 1 2",[62,3278,3279],{},"0xC8000012"," (the corrupted pointer causes the LZX decompressor to abort); ",[89,3282,3284],{"as":3283},"hex C 8 five zeroes 6",[62,3285,3286],{},"0xC8000006"," means a block was overwritten, which the code then verifies was block 14 by comparing ciphertexts.",[14,3289,3290,3293,3294,3300],{},[2627,3291,3292],{},"Thread 0"," (hardware thread 0) runs the tightest possible loop. The C reference implementation (",[89,3295,3297],{"as":3296},"Bad Update Poc dot cpp",[62,3298,3299],{},"BadUpdatePoc.cpp",") describes a more elaborate version with a 1024-slot hash table lookup and a 1.5-million-cycle delay, but the hand-assembled binary simplifies this to a direct single-canary comparison with no delay loop:",[73,3302,3304],{"className":2987,"code":3303,"language":2989,"meta":81,"style":81},"loop:\n    ld      r11, 0(r31)           ; load ciphertext from scratch buffer header\n    cmpld   cr6, r11, r30         ; does it match the canary?\n    bne     cr6, flush\n        mtctr   r25               ; hammer 100,000 writes\noverwrite:\n        std     r29, 0x20(r26)    ; overwrite dec_output_buffer ciphertext\n        std     r28, 0x28(r26)\n        dcbst   r0, r26           ; flush cache line to main memory\n        bdnz    overwrite\nflush:\n    dcbf    r0, r31               ; flush scratch buffer from cache\n    b       loop\n",[62,3305,3306,3311,3316,3321,3326,3331,3336,3341,3346,3351,3356,3361,3366],{"__ignoreMap":81},[107,3307,3308],{"class":109,"line":110},[107,3309,3310],{},"loop:\n",[107,3312,3313],{"class":109,"line":124},[107,3314,3315],{},"    ld      r11, 0(r31)           ; load ciphertext from scratch buffer header\n",[107,3317,3318],{"class":109,"line":143},[107,3319,3320],{},"    cmpld   cr6, r11, r30         ; does it match the canary?\n",[107,3322,3323],{"class":109,"line":159},[107,3324,3325],{},"    bne     cr6, flush\n",[107,3327,3328],{"class":109,"line":173},[107,3329,3330],{},"        mtctr   r25               ; hammer 100,000 writes\n",[107,3332,3333],{"class":109,"line":377},[107,3334,3335],{},"overwrite:\n",[107,3337,3338],{"class":109,"line":389},[107,3339,3340],{},"        std     r29, 0x20(r26)    ; overwrite dec_output_buffer ciphertext\n",[107,3342,3343],{"class":109,"line":405},[107,3344,3345],{},"        std     r28, 0x28(r26)\n",[107,3347,3348],{"class":109,"line":421},[107,3349,3350],{},"        dcbst   r0, r26           ; flush cache line to main memory\n",[107,3352,3353],{"class":109,"line":711},[107,3354,3355],{},"        bdnz    overwrite\n",[107,3357,3358],{"class":109,"line":719},[107,3359,3360],{},"flush:\n",[107,3362,3363],{"class":109,"line":727},[107,3364,3365],{},"    dcbf    r0, r31               ; flush scratch buffer from cache\n",[107,3367,3368],{"class":109,"line":733},[107,3369,3370],{},"    b       loop\n",[14,3372,3373,3377,3378,3382,3383,3388,3389,3393,3394,3400],{},[89,3374,3375],{"as":2898},[62,3376,2901],{}," starts decompressing and the LZX decoder writes its context header (",[89,3379,3380],{"as":3108},[62,3381,3111],{},") into the scratch buffer early in the process. Thread 0 detects this by comparing the ciphertext against a pre-loaded canary in ",[89,3384,3386],{"as":3385},"r30",[62,3387,3385],{},", then hammers 100,000 writes to replace the ",[89,3390,3391],{"as":2929},[62,3392,2932],{}," pointer at offset ",[89,3395,3397],{"as":3396},"hex 2 B 2 8",[62,3398,3399],{},"0x2B28"," in the scratch buffer with the pre-computed replacement that decrypts to the hypervisor address.",[14,3402,3403],{},"If the timing is right, the decompressor reads the attacker's pointer and writes its output into hypervisor code space.",[14,3405,3406,3407,3413,3414,3418],{},"The exploit targets block 14 of the compressed data because block 14 is the smallest block in the file at ",[89,3408,3410],{"as":3409},"hex 1 A D 0",[62,3411,3412],{},"0x1AD0"," bytes, which gives the widest race window; its decompressed output at offset ",[89,3415,3416],{"as":3165},[62,3417,3168],{}," contains a write-byte primitive:",[73,3420,3422],{"className":2987,"code":3421,"language":2989,"meta":81,"style":81},"stb     r4, 2(r6)\nblr\n",[62,3423,3424,3429],{"__ignoreMap":81},[107,3425,3426],{"class":109,"line":110},[107,3427,3428],{},"stb     r4, 2(r6)\n",[107,3430,3431],{"class":109,"line":124},[107,3432,3011],{},[14,3434,3435,3436,3440,3441,3444,3445,3451],{},"This instruction sequence lands in the hypervisor at offset ",[89,3437,3438],{"as":3172},[62,3439,3175],{},", giving the exploit the ability to write arbitrary bytes to arbitrary hypervisor addresses, one byte at a time with four calls for a 32-bit write; the ",[62,3442,3443],{},"+2"," displacement in the ",[89,3446,3448],{"as":3447},"store byte",[62,3449,3450],{},"stb"," instruction means every write address is offset by -2 to compensate.",[14,3453,3454],{},"The race doesn't hit every time. Grimdoomer's README cites a 30% success rate and up to 20 minutes per attempt - my console usually landed within the first few seconds, and when it didn't I'd wait about five minutes then restart.",[557,3456,3458],{"id":3457},"patching-the-hypervisor","Patching the Hypervisor",[14,3460,3461,3462,3468,3469,3475,3476,3482,3483,3489,3490,3494,3495,885],{},"The exploit uses the write primitive to overwrite the syscall table entry for ",[89,3463,3465],{"as":3464},"H V X Post Output",[62,3466,3467],{},"HvxPostOutput"," (syscall ",[89,3470,3472],{"as":3471},"hex 0 D",[62,3473,3474],{},"0x0D",") at ",[89,3477,3479],{"as":3478},"hex 8 triple-zero 0 1 0 2 triple-zero 1 5 F D 0, plus hex D times 4",[62,3480,3481],{},"0x8000010200015FD0 + (0xD * 4)"," with the address of a ",[89,3484,3486],{"as":3485},"move to count register r4, then branch to count register",[62,3487,3488],{},"mtctr r4; bctr"," gadget in the hypervisor, turning syscall ",[89,3491,3492],{"as":3471},[62,3493,3474],{}," into an arbitrary-address jump where the hypervisor branches to whatever address you pass in ",[62,3496,3497],{},"r4",[14,3499,3500,3501,3507,3508,3514,3515,3519,3520,2291,3526,3532,3533,3539,3540,2979,3546,3552,3553,3559,3560,3564,3565,2979,3571,3577],{},"The shellcode runs in hypervisor context. It restores the 64KB of hypervisor code the race corrupted by having ",[89,3502,3504],{"as":3503},"H V P Relocate Cache Lines",[62,3505,3506],{},"HvpRelocateCacheLines"," copy clean data from a backup binary (",[89,3509,3511],{"as":3510},"Stage 4 Clean H V Data Retail 17559 dot bin",[62,3512,3513],{},"Stage4_CleanHvData_Retail_17559.bin",") to physical address ",[89,3516,3517],{"as":3145},[62,3518,3148],{},". It patches RSA signature verification in the hypervisor by writing ",[89,3521,3523],{"as":3522},"hex 3 8 6 four zeroes 1",[62,3524,3525],{},"0x38600001",[89,3527,3529],{"as":3528},"load immediate r3 comma 1",[62,3530,3531],{},"li r3, 1",", meaning \"load immediate: return true\") to physical address ",[89,3534,3536],{"as":3535},"hex 8 triple-zero 0 1 0 4 dot triple-zero 2 9 B 0 4",[62,3537,3538],{},"0x80000104.00029B04"," to replace the call to ",[89,3541,3543],{"as":3542},"Xe Crypt B N Q W Be Sig Verify",[62,3544,3545],{},"XeCryptBnQwBeSigVerify",[89,3547,3549],{"as":3548},"H V P Image Signature Verification",[62,3550,3551],{},"HvpImageSignatureVerification"," so the hypervisor reports all signatures as valid. It then patches the same function in the kernel by disabling RMCI (Real Mode Cache Inhibit) via ",[89,3554,3556],{"as":3555},"H V P Set R M C I zero",[62,3557,3558],{},"HvpSetRMCI(0)"," to access encrypted kernel memory, writing the same ",[89,3561,3562],{"as":3528},[62,3563,3531],{}," patch to ",[89,3566,3568],{"as":3567},"hex 8 triple-zero 0 3 double-zero dot triple-zero 7 B F D C",[62,3569,3570],{},"0x80000300.0007BFDC",[89,3572,3574],{"as":3573},"X exp Verify Xex Headers",[62,3575,3576],{},"XexpVerifyXexHeaders"," and re-enabling RMCI after.",[14,3579,3580,3581,3585,3586,3595],{},"It returns ",[89,3582,3583],{"as":1792},[62,3584,1795],{}," as a success sentinel. The race code checks this value then calls ",[89,3587,3589,3592,3593],{"as":3588},"X Launch New Image with path payload backslash default dot xex, zero",[62,3590,3591],{},"{","XLaunchNewImage(\"PAYLOAD:\\\\default.xex\", 0)",[62,3594,2334],{}," to boot the unsigned payload from USB.",[14,3597,3598],{},"The entire chain, from a stack overflow in an avatar name field to unsigned code execution via hypervisor memory corruption, runs without touching the NAND flash - nothing is written to persistent storage, a power cycle returns it to a retail state.",[38,3600,3602],{"id":3601},"xeunshackle","XeUnshackle",[14,3604,3605],{},"XeUnshackle by Byrom90 is the payload that BadUpdate launches; it transforms the console into the functional equivalent of a JTAG (a permanent hardware exploit using the CPU's debug interface) or RGH-modded system in RAM by applying the same \"freeboot\" patch set that xeBuild uses when creating permanent NAND modifications, installing a hypervisor expansion for ongoing privileged access, loading DashLaunch and displaying the console's CPUKey (a unique per-console encryption key burned into the CPU's eFuses) and DVDKey (the key used to decrypt game disc content, stored in the keyvault).",[557,3607,3609],{"id":3608},"expansion-install-bypass","Expansion Install Bypass",[14,3611,3612,3613,3617,3618,3622,3623,3468,3629,3635,3636,3642,3643,3649,3650,3656,3657,3663,3664,3670,3671,3677],{},"XeUnshackle's first move is to use BadUpdate's ",[89,3614,3615],{"as":3464},[62,3616,3467],{}," backdoor (the hijacked syscall ",[89,3619,3620],{"as":3471},[62,3621,3474],{},") to run shellcode that patches ",[89,3624,3626],{"as":3625},"H V X Expansion Install",[62,3627,3628],{},"HvxExpansionInstall",[89,3630,3632],{"as":3631},"hex 7 2",[62,3633,3634],{},"0x72",") at three locations: HV offset ",[89,3637,3639],{"as":3638},"hex 3 0 8 9 C",[62,3640,3641],{},"0x3089C"," gets changed to ",[89,3644,3646],{"as":3645},"branch if not equal, condition register 6, plus 8",[62,3647,3648],{},"bne cr6, +8"," to skip the signature check failure branch, ",[89,3651,3653],{"as":3652},"hex 3 0 8 A 0",[62,3654,3655],{},"0x308A0"," becomes ",[89,3658,3660],{"as":3659},"load immediate r29 comma 0",[62,3661,3662],{},"li r29, 0"," to clear the check result, and ",[89,3665,3667],{"as":3666},"hex 3 0 8 A 4",[62,3668,3669],{},"0x308A4"," is NOPed out; together these disable RSA signature verification on hypervisor expansions and allow XeUnshackle to install its own unsigned expansion. The full freeboot patch set later rewrites the same handler, NOPing ",[89,3672,3674],{"as":3673},"hex 3 0 8 A 8",[62,3675,3676],{},"0x308A8"," as well.",[557,3679,3681],{"id":3680},"peekpoke-via-hv-expansion","Peek\u002FPoke via HV Expansion",[14,3683,3684,3685,3691,3692,3698,3699,3703,3704,3468,3710,3716,3717,1675,3723,1892,3729,1675,3732,3738],{},"With expansion checks disabled, XeUnshackle installs a custom hypervisor expansion (ID ",[89,3686,3688],{"as":3687},"hex 4 8 5 6 5 0 5 0",[62,3689,3690],{},"0x48565050"," \u002F ",[89,3693,3695],{"as":3694},"H V P P",[62,3696,3697],{},"HVPP",") via ",[89,3700,3701],{"as":3625},[62,3702,3628],{}," that provides read\u002Fwrite access to any hypervisor memory address from usermode through ",[89,3705,3707],{"as":3706},"H V X Expansion Call",[62,3708,3709],{},"HvxExpansionCall",[89,3711,3713],{"as":3712},"hex 7 3",[62,3714,3715],{},"0x73","), implementing a dispatch table where modes 0-3 read byte\u002Fhalfword\u002Fword\u002Fdoubleword, modes 5-8 write them, modes 4 and 9 do bulk transfers with cache coherency (",[89,3718,3720],{"as":3719},"data cache block store",[62,3721,3722],{},"dcbst",[107,3724,3726],{"as":3725},"instruction cache block invalidate",[62,3727,3728],{},"icbi",[62,3730,3731],{},"sync",[107,3733,3735],{"as":3734},"instruction sync",[62,3736,3737],{},"isync",") and modes 10-11 read\u002Fwrite Special Purpose Registers.",[14,3740,3741,3742,1675,3748,3754,3755,3761,3762,3768,3769,3772,3773,3779,3780,1675,3784,1892,3788,1675,3790,3794],{},"The SPR accessor needs a JIT. PPC's ",[89,3743,3745],{"as":3744},"move from S P R",[62,3746,3747],{},"mfspr",[107,3749,3751],{"as":3750},"move to S P R",[62,3752,3753],{},"mtspr"," instructions encode the SPR number into the opcode itself, meaning you can't use a register to specify which SPR to access; the expansion solves this by JIT-compiling the instruction at runtime, reading its own code address via ",[89,3756,3758],{"as":3757},"branch and link dot plus 4 semicolon move from link register",[62,3759,3760],{},"bl .+4; mflr",", computing a pointer ",[89,3763,3765],{"as":3764},"hex 3 0",[62,3766,3767],{},"0x30"," bytes ahead, inserting the SPR number from ",[62,3770,3771],{},"r5"," into the opcode template using ",[89,3774,3776],{"as":3775},"R L W I M I, rotate left word immediate then mask insert",[62,3777,3778],{},"rlwimi",", writing the modified instruction, flushing the instruction cache with ",[89,3781,3782],{"as":3719},[62,3783,3722],{},[107,3785,3786],{"as":3725},[62,3787,3728],{},[62,3789,3731],{},[107,3791,3792],{"as":3734},[62,3793,3737],{}," and branching to it - self-modifying hypervisor code generated per call.",[557,3796,3798],{"id":3797},"freeboot-patches","Freeboot Patches",[14,3800,3801],{},"With the peek\u002Fpoke expansion installed, XeUnshackle applies the full freeboot patch set in two phases.",[14,3803,3804,3807,3808,3814,3815,3821,3822,2979,3828,3831,3832,3834,3835,3841,3842,3846],{},[2627,3805,3806],{},"Phase 1 - Primary HV Patches"," go in first because they include the memory protection disable; the most important patch is at HV offset ",[89,3809,3811],{"as":3810},"hex B 5 1 0",[62,3812,3813],{},"0xB510",", where 288 bytes replace ",[89,3816,3818],{"as":3817},"H V X Get Version",[62,3819,3820],{},"HvxGetVersion"," (syscall 0) with a multi-function backdoor that checks for magic value ",[89,3823,3825],{"as":3824},"hex 7 2 6 2 7 4 7 2",[62,3826,3827],{},"0x72627472",[62,3829,3830],{},"r3"," and dispatches on ",[62,3833,3497],{}," (mode 2 disables hypervisor memory protections by writing ",[89,3836,3838],{"as":3837},"or-immediate r6 comma r6 comma 7",[62,3839,3840],{},"ori r6, r6, 7"," at the protection handler to force RWX permissions, mode 3 re-enables them, mode 4 does bulk memory copies), falling through to the real ",[89,3843,3844],{"as":3817},[62,3845,3820],{}," if the magic doesn't match.",[14,3848,3849,3850,3856,3857,3863],{},"After these primary patches, ",[89,3851,3853],{"as":3852},"H V X toggle mem protect false",[62,3854,3855],{},"Hvx::ToggleMemProtect(FALSE)"," calls ",[89,3858,3860],{"as":3859},"H V X Get Version with hex 7 2 6 2 7 4 7 2 comma 2",[62,3861,3862],{},"HvxGetVersion(0x72627472, 2)"," to use the backdoor it installed to disable HV memory protections, making the remaining address space writable.",[14,3865,3866,3869,3870,3876,3877,3883,3884,183,3888,3894],{},[2627,3867,3868],{},"Phase 2 - Secondary HV Patches"," dismantle the security chain with protections disabled. Most of these follow the same pattern: overwrite the target function with ",[89,3871,3873],{"as":3872},"load immediate r3 1, branch to link register, meaning return true",[62,3874,3875],{},"li r3, 1; blr"," or ",[89,3878,3880],{"as":3879},"load immediate r3 0, branch to link register, meaning return false or success",[62,3881,3882],{},"li r3, 0; blr"," depending on what the caller expects. RSA signature verification (",[89,3885,3886],{"as":3542},[62,3887,3545],{},[89,3889,3891],{"as":3890},"H V P P K C S 1 Verify",[62,3892,3893],{},"HvpPkcs1Verify","), security violation detection and activation (four separate get\u002Fset functions), key validation, image loading checks, import resolution, hash verification, image key transforms - all patched to return success unconditionally.",[14,3896,3897],{},"The mechanically interesting patches:",[14,3899,3900],{},"{\u002F* tts: Table of mechanically interesting patches. Row 1: H V P Image Signature Verification at H V offset hex 2 9 B 0 8, a 14 double-word replacement of the signature verification logic, not a simple load immediate, this is a full function rewrite. Row 2: Fuse blow handler at H V offset hex A 5 6 0, returns success without blowing e-fuses, the one-time-programmable hardware fuses the console burns to track update history. Row 3: Devkit XEX AES key at H V offset hex 0 0 F 0, zeroed out with 16 null bytes, disabling devkit encryption validation. Row 4: Machine check exceptions at H V offsets hex 7 2 B 4 through hex 7 2 E C, three NOPs plus load immediate r11 comma 1, suppressing hardware exception reporting. *\u002F}",[222,3902,3903,3916],{},[225,3904,3905],{},[228,3906,3907,3910,3913],{},[231,3908,3909],{},"What",[231,3911,3912],{},"Where",[231,3914,3915],{},"Effect",[241,3917,3918,3944,3960,3976],{},[228,3919,3920,3926,3935],{},[246,3921,3922],{},[89,3923,3924],{"as":3548},[62,3925,3551],{},[246,3927,3928,3929],{},"HV ",[89,3930,3932],{"as":3931},"hex 2 9 B 0 8",[62,3933,3934],{},"0x29B08",[246,3936,3937,3938,3943],{},"14-DWORD replacement of the signature verification logic (not a simple ",[89,3939,3941],{"as":3940},"load immediate r3 1",[62,3942,3531],{}," - this is a full rewrite of the function)",[228,3945,3946,3949,3957],{},[246,3947,3948],{},"Fuse blow handler",[246,3950,3928,3951],{},[89,3952,3954],{"as":3953},"hex A 5 6 0",[62,3955,3956],{},"0xA560",[246,3958,3959],{},"Returns success without blowing eFuses (one-time-programmable hardware fuses the console burns to track update history)",[228,3961,3962,3965,3973],{},[246,3963,3964],{},"Devkit XEX AES key",[246,3966,3928,3967],{},[89,3968,3970],{"as":3969},"hex 0 0 F 0",[62,3971,3972],{},"0x00F0",[246,3974,3975],{},"Zeroed out (16 null bytes) - disables devkit encryption validation",[228,3977,3978,3981,3995],{},[246,3979,3980],{},"Machine check exceptions",[246,3982,3928,3983,1730,3989],{},[89,3984,3986],{"as":3985},"hex 7 2 B 4",[62,3987,3988],{},"0x72B4",[107,3990,3992],{"as":3991},"hex 7 2 E C",[62,3993,3994],{},"0x72EC",[246,3996,3997,3998,4004],{},"Three NOPs + ",[89,3999,4001],{"as":4000},"load immediate r11 comma 1",[62,4002,4003],{},"li r11, 1"," - suppresses hardware exception reporting",[14,4006,4007],{},"Between the signature verification rewrite, the fuse blow suppression, the devkit key wipe and the machine check NOPs, the hypervisor no longer has any mechanism to detect, record or act on the fact that it's running unsigned code.",[14,4009,4010,4013,4014,1675,4020,1675,4026,4032,4033,4038],{},[2627,4011,4012],{},"Kernel Patches"," use the same primitive, applied to kernel virtual addresses via memcpy with cache flush (",[89,4015,4017],{"as":4016},"double-underscore data cache block store",[62,4018,4019],{},"__dcbst",[107,4021,4023],{"as":4022},"double-underscore sync",[62,4024,4025],{},"__sync",[107,4027,4029],{"as":4028},"double-underscore instruction sync",[62,4030,4031],{},"__isync","). The bulk are the same ",[89,4034,4036],{"as":4035},"load immediate r3 1 semicolon branch to link register",[62,4037,3875],{}," pattern across XEX (Xbox Executable - the 360's signed binary format) error handling, media type verification, version checks, RSA verification, revocation checks, HDD and DVD drive authentication and USB device security. The notable ones:",[14,4040,4041],{},"{\u002F* tts: Table of notable kernel patches. Row 1: Sata CD-ROM Authentication Ex Initialize at hex 8 double-zero 9 9 8 D 0, DVD drive auth threshold set to hex F F, accepting any drive. Row 2: Vd Display Fatal Error, the E66 error, at hex 8 double-zero 9 9 2 B 4, E66 error screen disabled. Row 3: Sata Disk Authenticate Device at hex 8 0 1 5 D 9 D 8, third-party HDDs accepted. *\u002F}",[222,4043,4044,4054],{},[225,4045,4046],{},[228,4047,4048,4050,4052],{},[231,4049,3909],{},[231,4051,3912],{},[231,4053,3915],{},[241,4055,4056,4084,4106],{},[228,4057,4058,4066,4074],{},[246,4059,4060],{},[89,4061,4063],{"as":4062},"Sata CD-ROM Authentication Ex Initialize",[62,4064,4065],{},"SataCdRomAuthenticationExInitialize",[246,4067,4068],{},[89,4069,4071],{"as":4070},"hex 8 double-zero 9 9 8 D 0",[62,4072,4073],{},"0x800998D0",[246,4075,4076,4077,4083],{},"DVD drive auth threshold set to ",[89,4078,4080],{"as":4079},"hex F F",[62,4081,4082],{},"0xFF"," (accepts any drive)",[228,4085,4086,4095,4103],{},[246,4087,4088,4094],{},[89,4089,4091],{"as":4090},"Vd Display Fatal Error",[62,4092,4093],{},"VdDisplayFatalError"," (E66)",[246,4096,4097],{},[89,4098,4100],{"as":4099},"hex 8 double-zero 9 9 2 B 4",[62,4101,4102],{},"0x800992B4",[246,4104,4105],{},"E66 error screen disabled",[228,4107,4108,4116,4124],{},[246,4109,4110],{},[89,4111,4113],{"as":4112},"Sata Disk Authenticate Device",[62,4114,4115],{},"SataDiskAuthenticateDevice",[246,4117,4118],{},[89,4119,4121],{"as":4120},"hex 8 0 1 5 D 9 D 8",[62,4122,4123],{},"0x8015D9D8",[246,4125,4126],{},"Third-party HDDs accepted",[14,4128,4129],{},"These three open up the hardware: any DVD drive works, the E66 fatal error that would brick the UI on a drive mismatch is gone; non-original HDDs are accepted without authentication.",[14,4131,4132,4133,4139,4140,4146,4147,4155,4156,2291,4162,4168,4169,2291,4175,4181,4182,2291,4188,4194],{},"At ",[89,4134,4136],{"as":4135},"hex 8 0 1 0 B F 4 0",[62,4137,4138],{},"0x8010BF40",", XeUnshackle injects 176 bytes into the now-unused ",[89,4141,4143],{"as":4142},"Xe Keys Console Signature Verification",[62,4144,4145],{},"XeKeysConsoleSignatureVerification"," function body containing the DashLaunch boot loader with a polling delay loop and the string ",[89,4148,4150,4152,4153],{"as":4149},"backslash Device backslash Flash backslash launch dot xex",[62,4151,3591],{},"\\Device\\Flash\\launch.xex",[62,4154,2334],{},", then redirects ",[89,4157,4159],{"as":4158},"Phase 1 Initialization",[62,4160,4161],{},"Phase1Initialization",[89,4163,4165],{"as":4164},"hex 8 double-zero 6 1 3 C C",[62,4166,4167],{},"0x800613CC","), ",[89,4170,4172],{"as":4171},"Xex Load Executable",[62,4173,4174],{},"XexLoadExecutable",[89,4176,4178],{"as":4177},"hex 8 double-zero 7 D 7 F 8",[62,4179,4180],{},"0x8007D7F8",") and ",[89,4183,4185],{"as":4184},"Xe Keys Get Key Properties",[62,4186,4187],{},"XeKeysGetKeyProperties",[89,4189,4191],{"as":4190},"hex 8 0 1 0 8 E 7 0",[62,4192,4193],{},"0x80108E70",") into this injected code to ensure DashLaunch loads at boot.",[557,4196,4198],{"id":4197},"reverting-badupdate","Reverting BadUpdate",[14,4200,4201,4202,4206,4207,4213,4214,4220,4221,4225,4226,4232,4233,4237,4238,4242,4243,885],{},"After patching and loading DashLaunch, XeUnshackle reverts BadUpdate's original patches: it restores the original ",[89,4203,4204],{"as":3464},[62,4205,3467],{}," syscall dispatch entry (",[89,4208,4210],{"as":4209},"hex four zeroes 2 5 4 0",[62,4211,4212],{},"0x00002540",") at HV ",[89,4215,4217],{"as":4216},"hex 1 6 double-zero 4",[62,4218,4219],{},"0x16004"," to close the exploit's initial backdoor, restores the original branch in ",[89,4222,4223],{"as":3548},[62,4224,3551],{}," at HV ",[89,4227,4229],{"as":4228},"hex 2 9 B 0 4",[62,4230,4231],{},"0x29B04"," since the freeboot patches at ",[89,4234,4235],{"as":3931},[62,4236,3934],{}," handle signature verification with more granularity; it also restores the original branch in ",[89,4239,4240],{"as":3573},[62,4241,3576],{}," at kernel ",[89,4244,4246],{"as":4245},"hex 8 double-zero 7 B F D C",[62,4247,4248],{},"0x8007BFDC",[14,4250,4251,4252,4256,4257,4261,4262,4266],{},"Freeboot's more nuanced signature handling replaces BadUpdate's crude ",[89,4253,4254],{"as":3940},[62,4255,3531],{}," bypasses, which DashLaunch depends on for loading patched retail-signed XEX files; the peek\u002Fpoke expansion and the custom ",[89,4258,4259],{"as":3817},[62,4260,3820],{}," handler provide all the privileged access the system needs from here, making the ",[89,4263,4264],{"as":3464},[62,4265,3467],{}," backdoor redundant.",[14,4268,4269,4270,4225,4276,4282],{},"One more patch: ",[89,4271,4273],{"as":4272},"H V P Protected Flags",[62,4274,4275],{},"HvpProtectedFlags",[89,4277,4279],{"as":4278},"hex 1 6 6 1 8",[62,4280,4281],{},"0x16618"," gets zeroed because these flags accumulate security violation records during boot before XeUnshackle patches anything; without clearing them the DVD drive checks would still report violations and cause \"disc unreadable\" errors.",[557,4284,4286],{"id":4285},"cpukey-dvdkey-1bl","CPUKey, DVDKey, 1BL",[14,4288,4289,4290,4296,4297,4303,4304,4310,4311,2782,4316,4320,4321,4327,4328,4334],{},"XeUnshackle then displays a screen showing four things: the CPUKey (read from fuse lines 3 and 5 via the peek\u002Fpoke expansion at ",[89,4291,4293],{"as":4292},"hex 8 four zeroes 2 five zeroes 2 four zeroes, plus, open paren, fuse times hex 4 0, shift-left 3, close paren",[62,4294,4295],{},"0x8000020000020000 + ((fuse * 0x40) &lt;&lt; 3)","); the DVDKey (read from the keyvault pointer at HV ",[89,4298,4300],{"as":4299},"hex triple-zero triple-zero 0 2 triple-zero 1 6 3 C 0",[62,4301,4302],{},"0x00000002000163C0",", offset ",[89,4305,4307],{"as":4306},"plus hex 1 double-zero",[62,4308,4309],{},"+0x100","); the console type (derived from ",[89,4312,4314],{"as":4313},"Xbox Hardware Info Flags bitwise-AND hex F followed by seven zeroes",[62,4315,2781],{},[89,4317,4318],{"as":1632},[62,4319,1635],{},"); and a 1BL dump (First Bootloader - the first code that runs on the CPU at power-on, burned into ROM - 32KB from ",[89,4322,4324],{"as":4323},"hex 8 four zeroes 2 followed by ten zeroes",[62,4325,4326],{},"0x8000020000000000",", saved as ",[89,4329,4331],{"as":4330},"Trinity dash 1 B L dot bin",[62,4332,4333],{},"Trinity-1bl.bin","). It plays a success animation. You press Back to exit and the console is the functional equivalent of a JTAG\u002FRGH system until you turn it off.",[38,4336,4338],{"id":4337},"semi-untethered-boot","Semi-Untethered Boot",[14,4340,4341],{},"BadUpdate is semi-untethered because all of XeUnshackle's patches live in DRAM, where the hypervisor, kernel and all system code run from memory loaded fresh from NAND flash on each boot; none of the exploit touches the flash, so power off and the DRAM contents are gone, the console boots from its unmodified NAND image as if nothing happened.",[14,4343,4344],{},"Each power cycle requires re-running the exploit. The boot flow:",[3052,4346,4347,4350,4357,4360],{},[2866,4348,4349],{},"Power on without touching the controller.",[2866,4351,4352,4353,885],{},"ABadAvatar triggers from the sign-in screen, where the front panel LEDs cycling between segments means the race is running with Thread 0 monitoring ciphertext and Thread 1 hammering ",[89,4354,4355],{"as":2898},[62,4356,2901],{},[2866,4358,4359],{},"LEDs go solid green once XeUnshackle has patched the hypervisor and kernel.",[2866,4361,4362,4363,4369],{},"Press Back; DashLaunch reads ",[89,4364,4366],{"as":4365},"launch dot I N I",[62,4367,4368],{},"launch.ini",", loads plugins, boots Aurora, signs in the profile.",[14,4371,4372],{},"On my console most boots hit within the first few seconds; when they didn't, the five-minute restart usually got there - the trade-off for not soldering anything.",[1204,4374,4375],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":81,"searchDepth":124,"depth":124,"links":4377},[4378,4379,4380,4387,4394],{"id":2771,"depth":124,"text":2772},{"id":2792,"depth":124,"text":2793},{"id":2854,"depth":124,"text":2855,"children":4381},[4382,4383,4384,4385,4386],{"id":2891,"depth":143,"text":2892},{"id":2940,"depth":143,"text":2941},{"id":3037,"depth":143,"text":3038},{"id":3206,"depth":143,"text":3207},{"id":3457,"depth":143,"text":3458},{"id":3601,"depth":124,"text":3602,"children":4388},[4389,4390,4391,4392,4393],{"id":3608,"depth":143,"text":3609},{"id":3680,"depth":143,"text":3681},{"id":3797,"depth":143,"text":3798},{"id":4197,"depth":143,"text":4198},{"id":4285,"depth":143,"text":4286},{"id":4337,"depth":124,"text":4338},{"canListen":741},"\u002Fposts\u002Fxb360-exploit-chain",{"title":2750,"description":2755},"posts\u002Fxb360-exploit-chain","From a stack overflow in an avatar name field to hypervisor shellcode.","XL4ahWY7vovUhSNaDw1oJUNYbTDJxXLygeox-kzWKPU",1789118032438]