Skip to content

Commit 838338f

Browse files
committed
Stop an export from deleting chunks it was never asked about
Somebody walked the whole path in the native window and reported two things about the end of it. Looking into those found a third, which is the worst defect this project has had. An export replaced whole region files. A region file holds up to 1,024 chunks and an export usually has a handful in any one of them, so laying the file out from what it was given and writing it over the old one deleted the rest: chunks this archive never observed and had no standing to touch. Nothing failed, because a shorter file is not an error. Their world went from 16 MB to 2.8 MB. Its point-of-interest data, which an export does not write, still names 29 chunks in the two regions that were replaced, and those chunks are gone. Nothing had warned them: the heuristic that asks "have you played in this?" only speaks above 40 MB and that world was 16. Region.Adopt now reads a region file that is already there and keeps every chunk the export does not supply. Frames are copied through without being decompressed, parsed or re-encoded, because this archive did not observe them and has no business having an opinion about their contents, only about not losing them. Where the two meet the export wins, so it can still correct what it wrote before. A file that cannot be read stops the export rather than being replaced by a smaller one, and it is read before anything is written, so a refusal leaves the world alone. Measured on the data that exposed it: writing the six chunks this archive holds for top.earthmc.net into a copy of that world, whose region file held 380, reported 374 kept, and the world's chunk count across all six region files was 552 before and 552 after. With adoption disabled the same test finds 2 chunks where 14 should be. ExportReport carries Kept, the window reports it, and the confirmation stops saying recordings are written over "where they overlap what is there", which described chunks and was true of nothing. The two things they actually reported are fixed too. The window never declared DPI awareness, and neither does the library it uses, so on any display scaled above 100% Windows drew it for 96 dots per inch and stretched the result -- everything working, every letter soft. It now asks for per-monitor awareness through three generations of the same call, and scales its opening size to match, because a window asked for in 96-dpi pixels comes out physically small once the process is aware. And the path ended in one green line where the whole screen had been. The last screen now answers what actually gets asked there: what went in, what was already in that world and survived, which files were written, where it is, and what to do next. On the same run the install had refused after they typed a name, read five file paths and agreed to all five, because a build from source carries no address for the mod; that refusal now happens before the asking, since consent collected for something that cannot happen is worse than not asking at all.
1 parent 6af33ec commit 838338f

10 files changed

Lines changed: 514 additions & 16 deletions

File tree

cmd/worldledger/export.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ func bindWorldFlags(fs *flag.FlagSet, request *worldRequest) {
276276
fs.StringVar(&request.dimension, "dimension", "minecraft:overworld", "dimension id")
277277
fs.StringVar(&request.moment, "at", "", "RFC3339 reconstruction time (default now)")
278278
fs.StringVar(&request.into, "into", "", "existing Minecraft world directory")
279-
fs.BoolVar(&request.overwrite, "overwrite", false, "replace existing region files")
279+
fs.BoolVar(&request.overwrite, "overwrite", false, "write into region files that already exist, replacing only the chunks this export has")
280280
}
281281

282282
// cmdExport writes the observed state unchanged. It never approximates, so the

desktop/internal/api/exporting.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ type exportAnswer struct {
4444
WorldDir string `json:"world_dir"`
4545
Unknown int `json:"unknown"`
4646
Withheld int `json:"withheld,omitempty"`
47+
// Kept is how many chunks were already in the files this wrote into and
48+
// were left as they were. It is the answer to the question somebody
49+
// actually has about writing into a world they care about.
50+
Kept int `json:"kept"`
4751
}
4852

4953
func handleExport(w http.ResponseWriter, r *http.Request) {
@@ -160,6 +164,7 @@ func handleExport(w http.ResponseWriter, r *http.Request) {
160164
Chunks: report.Chunks,
161165
RegionFiles: shortNames(report.RegionFiles),
162166
WorldDir: request.WorldDir,
167+
Kept: report.Kept,
163168
Unknown: snapshot.Summary.Unknown,
164169
Withheld: inputs.Withheld,
165170
}

desktop/internal/api/installing.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ func handlePlan(w http.ResponseWriter, r *http.Request) {
4747
"install Minecraft and play it once, then come back")
4848
return
4949
}
50+
// Refused here rather than after the plan has been shown and agreed to.
51+
//
52+
// A build from source carries no address for the mod, so installing was
53+
// always going to fail. It failed at the end: somebody typed their name,
54+
// read five file paths, agreed to all five, and then got a line of small
55+
// text. Consent had been collected for something that could not happen,
56+
// which is worse than not asking, and the same refusal a step earlier costs
57+
// nothing and is true at the moment it is said.
58+
if ModSource == "" {
59+
app.WriteJSON(w, http.StatusOK, installer.Plan{
60+
Refusal: "this build of the application does not know where to get the mod from, " +
61+
"so it cannot set anything up. Use a released version, which carries that address.",
62+
})
63+
return
64+
}
5065
contributor := r.URL.Query().Get("contributor")
5166
app.WriteJSON(w, http.StatusOK,
5267
installer.BuildPlan(install, health.Inspect(install), ModSource, contributor))
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//go:build windows
2+
3+
package shell
4+
5+
import (
6+
"golang.org/x/sys/windows"
7+
)
8+
9+
// Telling Windows this program can draw at the screen's real resolution.
10+
//
11+
// Without it a process is treated as though it were written for 96 dots per
12+
// inch, and on any display scaled above 100% Windows renders the window at that
13+
// size and then stretches the result. Everything still works and every letter
14+
// is soft, which is how this was found: somebody used the window and said the
15+
// text was blurry.
16+
//
17+
// It has to happen before a window exists and can only be set once per process,
18+
// so it is the first thing runWindow does.
19+
20+
var (
21+
user32 = windows.NewLazySystemDLL("user32.dll")
22+
shcore = windows.NewLazySystemDLL("shcore.dll")
23+
setProcessDpiAwarenessContext = user32.NewProc("SetProcessDpiAwarenessContext")
24+
setProcessDPIAware = user32.NewProc("SetProcessDPIAware")
25+
getDpiForSystem = user32.NewProc("GetDpiForSystem")
26+
setProcessDpiAwareness = shcore.NewProc("SetProcessDpiAwareness")
27+
perMonitorAwareV2 = ^uintptr(3) // the -4 handle constant
28+
processPerMonitorDpiAware = uintptr(2)
29+
defaultDotsPerInch float64 = 96
30+
)
31+
32+
// declareDPIAware asks for the best awareness this Windows offers.
33+
//
34+
// The three calls are three generations of the same request, and a machine only
35+
// answers one of them. None of them failing is not worth reporting: the window
36+
// still opens and still works, it is only soft, and a program that refuses to
37+
// start over that would be trading a real feature for a cosmetic one.
38+
func declareDPIAware() {
39+
if err := setProcessDpiAwarenessContext.Find(); err == nil {
40+
if ok, _, _ := setProcessDpiAwarenessContext.Call(perMonitorAwareV2); ok != 0 {
41+
return
42+
}
43+
}
44+
if err := setProcessDpiAwareness.Find(); err == nil {
45+
if result, _, _ := setProcessDpiAwareness.Call(processPerMonitorDpiAware); result == 0 {
46+
return
47+
}
48+
}
49+
if err := setProcessDPIAware.Find(); err == nil {
50+
setProcessDPIAware.Call()
51+
}
52+
}
53+
54+
// windowScale is how much bigger than its nominal size the window should be
55+
// asked for.
56+
//
57+
// The size passed to the window is in real pixels, so once this process draws
58+
// at the screen's resolution a window asked for in 96-dpi numbers comes out
59+
// physically smaller on a scaled display -- the blur is gone and the window is
60+
// two thirds of the size it was meant to be. Scaling the request puts it back.
61+
func windowScale() float64 {
62+
if err := getDpiForSystem.Find(); err != nil {
63+
return 1
64+
}
65+
dpi, _, _ := getDpiForSystem.Call()
66+
if dpi == 0 {
67+
return 1
68+
}
69+
scale := float64(dpi) / defaultDotsPerInch
70+
// A display reporting something absurd should not produce a window that
71+
// cannot be reached or cannot be read.
72+
if scale < 1 {
73+
return 1
74+
}
75+
if scale > 4 {
76+
return 4
77+
}
78+
return scale
79+
}

desktop/internal/shell/window_windows.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,19 @@ func runWindow(url string) (note string, ran bool) {
3030
}
3131
}()
3232

33+
// Before the window exists, and only useful before it exists.
34+
declareDPIAware()
35+
scale := windowScale()
36+
3337
view := webview2.NewWithOptions(webview2.WebViewOptions{
3438
Debug: false,
3539
AutoFocus: true,
3640
WindowOptions: webview2.WindowOptions{
37-
Title: "WorldLedger",
38-
Width: 1100,
39-
Height: 760,
41+
Title: "WorldLedger",
42+
// Asked for in real pixels, so a display scaled above 100% needs a
43+
// bigger number for the same window.
44+
Width: uint(1100 * scale),
45+
Height: uint(760 * scale),
4046
Center: true,
4147
},
4248
})

desktop/ui/assets/app.js

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -769,11 +769,15 @@ async function refreshWorld() {
769769
button.addEventListener('click', async () => {
770770
if (world.sizeable && !await ask({
771771
title: 'Write into ' + world.name + '?',
772-
lead: 'It is ' + bytes(world.bytes) + ', which usually means somebody has played in it. ' +
773-
'Where your recordings overlap what is there, yours are written over it.',
772+
lead: 'It is ' + bytes(world.bytes) + ', which usually means somebody has played in it.',
774773
rows: [
775774
{ title: 'Writing', detail: chosenServer },
776775
{ title: 'Into', detail: world.path },
776+
{
777+
title: 'Where the two meet',
778+
detail: 'a place you recorded that also exists there is replaced by yours; ' +
779+
'everywhere else in that world is left exactly as it is',
780+
},
777781
],
778782
confirm: 'Write into it anyway',
779783
danger: true,
@@ -787,14 +791,7 @@ async function refreshWorld() {
787791
method: 'POST',
788792
body: JSON.stringify({ server: chosenServer, world_dir: world.path, at: when.value }),
789793
});
790-
const moment = when.value ? ' as it was on ' + momentLabel(moments, when.value) : '';
791-
host.replaceChildren(banner('good',
792-
'Wrote ' + result.chunks + ' places from ' + chosenServer + moment + ' into ' + world.name,
793-
'Open Minecraft and play that world. Anything nobody saw is left as the empty world made it.'));
794-
if (result.withheld) {
795-
host.append(el('p', 'quiet',
796-
result.withheld + ' recording(s) were held back by a redaction and are not in it.'));
797-
}
794+
host.replaceChildren(finished(result, world, when.value ? momentLabel(moments, when.value) : ''));
798795
} catch (err) {
799796
button.disabled = false;
800797
button.textContent = 'Write into this';
@@ -815,6 +812,47 @@ function howToMakeOne(answer) {
815812
return list;
816813
}
817814

815+
// The end of the path, which used to be one green line where the whole screen
816+
// had been.
817+
//
818+
// Somebody walked all six steps and said afterwards that they could not tell
819+
// what had happened. They were right: the screen emptied itself and left a
820+
// sentence, in a place where the reasonable questions are what went in, what was
821+
// already there, where it is, and what to do now. Those are four different
822+
// answers and none of them fits in a banner.
823+
function finished(result, world, moment) {
824+
const done = document.createDocumentFragment();
825+
done.append(banner('good', 'Your world is ready',
826+
'It is called ' + world.name + '. Open Minecraft, choose Singleplayer, and play it.'));
827+
828+
const facts = el('div', 'facts');
829+
addFact(facts, 'Places written', String(result.chunks) + ' chunk(s) from ' + chosenServer);
830+
if (moment) addFact(facts, 'As it was on', moment);
831+
// The number that answers "did this eat my world", which is the question the
832+
// export used to leave hanging because it did not know the answer itself.
833+
if (result.kept) {
834+
addFact(facts, 'Already there, left alone', String(result.kept) + ' chunk(s)');
835+
}
836+
if (result.unknown) {
837+
addFact(facts, 'Recorded but unreadable', String(result.unknown) + ' chunk(s), not written');
838+
}
839+
if (result.withheld) {
840+
addFact(facts, 'Held back by a redaction', String(result.withheld) + ' recording(s)');
841+
}
842+
addFact(facts, 'Files written', (result.region_files || []).join(', ') || 'none');
843+
addFact(facts, 'World folder', result.world_dir || world.path);
844+
done.append(facts);
845+
846+
done.append(el('p', 'quiet',
847+
'Anywhere nobody went is left exactly as the empty world generated it, because an archive ' +
848+
'that fills in what it never saw is guessing. Nothing else in that world was touched.'));
849+
850+
const again = el('button', 'fix', 'Write another world');
851+
again.addEventListener('click', refreshWorld);
852+
done.append(again);
853+
return done;
854+
}
855+
818856
// Time travel --------------------------------------------------------------
819857

820858
const travelColours = {

docs/status.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,26 @@ Two smaller ones came from the same walk. 608 recordings already taken in were o
7575

7676
**How the application asks.** Everything consequential — writing five files into a Minecraft, removing them again, deleting recordings, writing over a world somebody has played in — was agreed to in `window.confirm`. That heads the question with `127.0.0.1:53211 says`, renders a list of file paths as one run of text, and makes consent depend on script dialogs being switched on in whatever is showing the page. It is now a sheet in the application, with each file as its own row and its full path; Escape and Cancel both answer no. Checked in the running application: the five planned files appear as five rows, cancelling wrote nothing, and a `mods` folder still does not exist on the machine it was tried on.
7777

78+
**The worst defect this project has had, found by somebody using the window.**
79+
80+
An export replaced whole region files. A region file holds up to 1,024 chunks; an export usually has a handful in any one of them; the file was laid out from what the export was given and written over what was there. Every other chunk in it was deleted — chunks this archive never observed and had no standing to touch — and nothing failed, because a shorter file is not an error.
81+
82+
It was found by a person walking the path in the native window. They exported into a world they had made two weeks earlier, and it went from 16 MB to 2.8 MB. The world's own point-of-interest data, which the export does not write, still names 29 chunks in the two regions it replaced; those chunks are gone. Nothing had warned them, because the size heuristic that asks "have you played in this?" only speaks above 40 MB and that world was 16.
83+
84+
Three things were wrong at once and all three are fixed:
85+
86+
- **The write was destructive.** `Region.Adopt` now reads a region file that is already there and keeps every chunk the export does not supply, copying the frames through without decompressing, parsing or re-encoding them: this archive did not observe them and has no business having an opinion about their contents, only about not losing them. Where the two meet, the export wins, so it can still correct what it wrote before. A region file that cannot be read stops the export instead of being replaced by a smaller one, and it is read before anything is written, so a refusal leaves the world alone.
87+
- **It was measured on the data that exposed it.** Writing the 6 chunks the archive holds for `top.earthmc.net` into a copied world whose region file held 380 reported 374 kept, and the world's chunk count across all six region files was 552 before and 552 after. Before the fix the same operation left that file with 6.
88+
- **The confirmation was reassuringly wrong.** It said recordings are written over "where they overlap what is there", which describes chunks and was true of nothing. It now says a place you recorded that also exists there is replaced and everywhere else is left alone, which is what the code does.
89+
90+
`ExportReport` carries `Kept`, and the window reports it, because "did this eat my world" is the question somebody actually has and the export previously could not answer it.
91+
92+
**Two more things that person found, which the walkthrough before them had not.**
93+
94+
The window is now told to draw at the display's real resolution. It never declared DPI awareness and the library it uses does not either, so on any display scaled above 100% Windows rendered it for 96 dots per inch and stretched the result: everything worked and every letter was soft. The initial size is scaled to match, because a window asked for in 96-dpi pixels comes out physically small once the process is aware.
95+
96+
And the path ended without saying what had happened. It ended in one green line where the whole screen had been — no count, no folder, no files, nothing about what was already in that world. The last screen now answers the four questions that are actually asked there: what went in, what was already there and survived, where it is, and what to do next. On the same run, the install had also refused *after* the person had typed a name, read five file paths and agreed to all five, because a build from source carries no address for the mod; that refusal now happens before the asking, since consent collected for something that cannot happen is worse than not asking.
97+
7898
**In the native window, as far as it was watched.** All of the above was exercised through the browser, which is the same page and the same server but not the same renderer. The window was then opened on Windows against the WebView2 runtime 151.0.4129.101 and a capture of it shows the application drawing: the numbered rail, the disposition cards, and a server's existing decision shown as the selected one. Within four seconds of that window opening, the first-run notice was accepted and written to disk, so the sheet appeared in the window, its button answered, and the request reached the archive's own directory — but the click was not this project's to claim, because somebody was at the keyboard, and it is recorded here as what it is rather than as a run.
7999

80100
What was not done is driving that window further. Doing so means taking over the screen of whoever is using the machine, and the attempt to do it captured a window that had nothing to do with this project, which is reason enough to stop. The confirmation sheet is therefore covered in the window only by what it shares with the notice: they are one element wearing two ids, with the same class and the same code, and the contract test under `desktop/ui` fails if they stop being so. That is a smaller claim than a run and it is the one the evidence supports.

0 commit comments

Comments
 (0)