Compare commits

..

60 Commits

Author SHA1 Message Date
f7ac3c4009 Add support for more Noita builds
All checks were successful
Build and test / Build and test (push) Successful in 2m47s
- Build Aug 12 2024 21:10:13
- Build Aug 12 2024 21:48:01
2024-10-03 21:17:07 +02:00
David Vogel
09d8bcfe09
Merge pull request #31 from WUOTE/12-August-2024-latest
Add support for the latest Beta branch build
2024-09-02 13:59:12 +02:00
b76233caa4 Fix indentation of modifications.lua 2024-09-02 13:57:29 +02:00
b6224e657f Add support for newest dev build 2024-09-02 13:56:22 +02:00
WUOTE
3e5950810a Update modification.lua 2024-09-02 16:44:49 +06:00
d3edf29a80 Add animation capture mode 2024-07-03 00:16:38 +02:00
David Vogel
3fd0d970b7
Merge pull request #30 from Dadido3/dependabot/go_modules/golang.org/x/image-0.18.0
Bump golang.org/x/image from 0.14.0 to 0.18.0
2024-06-26 21:55:23 +02:00
dependabot[bot]
627dc545d4
Bump golang.org/x/image from 0.14.0 to 0.18.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.14.0 to 0.18.0.
- [Commits](https://github.com/golang/image/compare/v0.14.0...v0.18.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-26 19:38:05 +00:00
203a0ea159 Fix predefined area error 2024-05-07 01:12:11 +02:00
0065afe413 Update AREAS.md 2024-04-29 10:03:24 +02:00
9c6e57b340 Merge branch 'master' of https://github.com/Dadido3/noita-mapcap 2024-04-29 09:52:10 +02:00
44ed26952c Replace hardcoded capture areas
We now retrieve the correct biome size and offsets from the game itself.
2024-04-29 09:52:07 +02:00
David Vogel
1aa8b02882
Merge pull request #28 from Dadido3/dependabot/go_modules/golang.org/x/net-0.23.0
Bump golang.org/x/net from 0.19.0 to 0.23.0
2024-04-21 00:44:01 +02:00
dependabot[bot]
e125df80b2
Bump golang.org/x/net from 0.19.0 to 0.23.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.19.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.19.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-19 12:45:14 +00:00
1936dc100c Add error handling to glReadPixels 2024-04-11 11:39:35 +02:00
1d83b0dfa0 Update README.md 2024-04-10 19:45:57 +02:00
David Vogel
442c8f7f43
Merge pull request #27 from WUOTE/master
Add support for Epilogue 2 Main branch Build Apr  8 2024 18:11:27
2024-04-10 11:05:59 +02:00
dcfe240bfc Add support for Noita "Build Apr 8 2024 18:07:16" 2024-04-10 11:03:06 +02:00
WUOTE
42f3af0655 Add support for Epilogue 2 Main branch Build Apr 8 2024 18:11:27 2024-04-10 05:14:07 +06:00
4e79c08e6a Add support for new Noita beta
- Add support for `Build Apr  6 2024 20:50:04`
- Add support for `Build Apr  6 2024 20:54:23`
2024-04-06 23:28:56 +02:00
aee72fd3c6 Allow reading tiles with transparency 2024-04-06 23:28:15 +02:00
04eeca26e2 Rename parallel world capture areas 2024-03-26 12:11:32 +01:00
ae6bcd9743 Add -1 and +1 parallel world capture area 2024-03-26 12:07:50 +01:00
920288614b Add support for Mar 25 2024 update
- Add support for `Build Mar 25 2024 17:42:49`
- Add support for `Build Mar 25 2024 17:48:04`
2024-03-25 23:02:34 +01:00
c0fd7aab56 Fix capture grid calculation
The capture grid calculation takes now the rendering rectangle into consideration.
With this fix the capture grid will include all cells that overlap with the given capture rectangle.
2024-02-21 17:55:24 +01:00
20d7c330a6 Add new capture area preset
"3 Worlds" does contain the base layout of a "New Game", but 9 times (3x3).
2024-02-20 18:02:21 +01:00
05e6b18745 Add support for Noita build Build Feb 14 2024 07:46:57 2024-02-20 17:43:25 +01:00
David Vogel
f109c66152
Merge pull request #26 from WUOTE/master
Add support for Build Feb 12 2024 19:07:19 (beta branch)
2024-02-16 22:51:50 +01:00
WUOTE
8b2fe81f42 Add support for Build Feb 12 2024 19:07:19 (beta branch) 2024-02-17 02:08:30 +06:00
David Vogel
0758ec7a62
Merge pull request #25 from WUOTE/master
Add compatibility for beta build (Feb 9 2024)
2024-02-11 19:04:02 +01:00
WUOTE
a9141679f4 Add compatibility for beta build (Feb 9 2024) 2024-02-12 00:01:30 +06:00
690a2c55ab Add support for newest main builds of Noita
`Build Jan 18 2024 12:57:44` and `Build Jan 18 2024 13:01:21`.
2024-02-10 12:50:15 +01:00
751877b472 Fix typo in stitcher flag
`wepb-level` --> `webp-level`
2024-02-08 21:07:42 +01:00
b4dca53fc8 Update capture process
- When being in `CaptureDelay` mode, just shake viewport a little bit
- When DoesWorldExistAt still does return false after 600 frames, move viewport to somewhere else, and try again.
2024-02-08 16:27:57 +01:00
9cfb01187c Fix invalid memory access in capture.dll 2024-02-08 16:12:55 +01:00
dddaad938f Fix release archive using wrong path delimiter
This caused the files in the .zip file to appear flattened when opened with 7zip.
2024-02-08 11:48:32 +01:00
41271d5321 Update README.md 2024-02-08 01:22:17 +01:00
b1a10870c1 Several changes
- Add compatibility for newest Noita beta
- Modify STREAMING_CHUNK_TARGET, GRID_MAX_UPDATES_PER_FRAME and GRID_MIN_UPDATES_PER_FRAME magic numbers for a more robust capturing process
- Add LimitGroup to util.go
- Add webp-level command line flag to define the webp compression level
- Rework progress bar to make it work in DZI export mode
- Refactor image exporter functions
- Use LimitGroup to make DZI export multithreaded
- Add BlendMethodFast which doesn't mix tile pixels
- Up Go version to 1.22
- Use Dadido3/go-libwebp for WebP encoding
2024-02-08 00:50:11 +01:00
47d570014d Make addon compatible with newest Noita beta 2024-02-05 22:31:56 +01:00
421f897be7 Let camera shake when it's waiting for a capture
For some reason this improves chunk loading.
2024-02-05 22:31:30 +01:00
9c728e0ae2 Update .gitignore 2024-02-05 22:30:22 +01:00
d9d8c9cd78 Format Capture.pb 2024-02-05 19:10:32 +01:00
15e2b88ed5 Update the Capture.dll
- Increase hardcoded number of workers to 8.
- Export images temporarily, and then move them once they are fully exported. This prevents corrupt images.
2024-02-05 19:00:39 +01:00
9e51538f3f Add setting to delay screen captures
This is useful to let the world populate and let the physics simulation settle down.
2024-02-05 18:10:10 +01:00
24a1615706 Prevent duplicate modification entries in message 2024-02-05 16:14:48 +01:00
45df692b96 Save top left coordinates when exporting DZI 2024-02-05 00:10:10 +01:00
93a1283188 Add more PostFX stuff to disable
- Disable additive_overlay_color
- Disable color_grading

This prevents any color shift that may happen on freezing/snowing weather.
Which only happens in December, January or February.
2024-02-05 00:03:55 +01:00
ace1ab145a More QOL updates
- Give user the option to reapply resolution settings on detected mismatch
- Tell user to apply some modifications manually, if the mod can't do it automatically
- Always set mTrailerMode when DEBUG_PAUSE_GRID_UPDATE is set to prevent chunks from not rendering
2024-01-30 15:01:20 +01:00
4f3f5c594d Some QOL improvements
- Always try to disable `application_rendered_cursor`
- Only disable fullscreen when custom resolution is enabled in mod settings
- Update Message:ShowWrongResolution message
- Update README.md
2024-01-29 16:27:06 +01:00
f22ef05411 Prevent transparent background 2024-01-15 21:31:45 +01:00
d82fda528a Merge remote-tracking branch 'origin/opengl-capture' 2024-01-15 21:29:23 +01:00
860b724bd0 Change DZI to encode tiles to WebP 2024-01-15 21:27:44 +01:00
8057b14d8e Update build.release.yml 2024-01-15 21:20:44 +01:00
0e431c64d3 Update build.release.yml 2024-01-15 21:16:53 +01:00
f2b1aba994 Enable CGO which is needed for cross compilation 2024-01-15 20:30:28 +01:00
1a735c06bd Update Readme.md 2024-01-15 20:30:12 +01:00
69f5d1ccb3 Add WebP encoder 2024-01-15 20:06:49 +01:00
44605b9633 Move coroutine wakeup back into OnWorldPreUpdate 2024-01-05 18:43:10 +01:00
d5cd88a30e Capture directly from OpenGL framebuffer
- Update capture.dll to read via glReadPixels
- Move coroutine wake up into OnWorldPostUpdate
- Update resolution checks for new capturing method
- Remove fullscreen mode check
- Increase screen capture delay
2024-01-04 19:36:36 +01:00
4de83e3dcd Fix typos 2023-12-31 18:22:32 +01:00
32 changed files with 1020 additions and 276 deletions

View File

@ -8,18 +8,17 @@ jobs:
build:
name: Build and release
runs-on: ubuntu-latest
runs-on: windows-latest
strategy:
matrix:
goos: [windows]
goarch: ["amd64"]
steps:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: ^1.21
go-version: ^1.22
- name: Check out code into the Go module directory
uses: actions/checkout@v2
@ -32,6 +31,7 @@ jobs:
env:
GOARCH: ${{ matrix.goarch }}
GOOS: ${{ matrix.goos }}
CGO_ENABLED: 1
- name: Create distribution archive
run: go run -v ./scripts/dist

View File

@ -12,7 +12,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: ^1.21
go-version: ^1.22
- name: Check out code into the Go module directory
uses: actions/checkout@v2

8
.gitignore vendored
View File

@ -105,5 +105,9 @@ $RECYCLE.BIN/
/output/
/dist/
/bin/stitch/output.png
/files/magic-numbers/generated.xml
/bin/stitch/*.png
/bin/stitch/*.dzi
/bin/stitch/*_files/
/files/magic-numbers/generated.xml
/bin/stitch/captures/*

11
.vscode/settings.json vendored
View File

@ -1,18 +1,22 @@
{
"cSpell.words": [
"aabb",
"acidflow",
"appdata",
"autosetup",
"backbuffer",
"basicfont",
"bytecode",
"cheggaaa",
"Dadido",
"dofile",
"dont",
"Downscales",
"downscaling",
"DPMM",
"executables",
"framebuffer",
"framebuffers",
"Fullscreen",
"goarch",
"gridify",
@ -24,6 +28,7 @@
"Lanczos",
"lann",
"ldflags",
"libwebp",
"linearize",
"longleg",
"lowram",
@ -36,24 +41,30 @@
"nfnt",
"Niccoli",
"noita",
"noitamap",
"Nolla",
"NXML",
"openseadragon",
"pixelated",
"polymorphed",
"promptui",
"rasterizer",
"Regen",
"respawn",
"runfast",
"savegames",
"schollz",
"screenshake",
"svenstaro",
"tcnksm",
"tdewolff",
"unmodded",
"unstitchable",
"upscaled",
"Vogel",
"Voronoi",
"webp",
"wepb",
"xmax",
"xmin",
"ymax",

View File

@ -1,6 +1,10 @@
# Capture areas
A list of available capture areas.
Other game-modes or mods may use a different biome setup, and therefore the coordinates shown here are not valid for them.
The values shown are for an unmodded `New Game` world.
The noita-mapcap mod will always automatically determine the required coordinates so that it correctly captures the base layout or multiples of it.
Coordinates are in in-game "virtual" or "world" pixels.
`Right` and `Bottom` coordinates are not included in the rectangle.
@ -64,3 +68,16 @@ Bottom = 41984
The end result will have a size of `51200 x 73728 pixels ~= 3775 megapixels`.
![Base layout](images/scale32_extended.png)
## `3 Worlds`
This area consists of `Main world` plus a full left and right parallel world.
``` lua
Left = -53760
Top = -31744
Right = 53760
Bottom = 41984
```
The end result will have a size of `107520 x 73728 pixels ~= 7927 megapixels`.

View File

@ -5,7 +5,10 @@ It works with the regular Noita build and the dev build.
![Title image](images/title.png)
A resulting image with nearly 3.8 gigapixels can be [seen here](https://easyzoom.com/image/223556) (May contain spoilers).
Map captures created with this mod can be viewed on [map.runfast.stream] (may contain spoilers).
If you are interested in creating similar captures, or if you want to contribute your own captures to [map.runfast.stream], you can take a look at [github.com/acidflow-noita/noitamap].
There you'll find detailed step-by-step instructions on how to quickly capture large parts of the Noita world with as little visual glitches and other issues as possible.
## System requirements
@ -55,8 +58,7 @@ To the top left of the window are 3 buttons:
- ![Output directory button](files/ui-gfx/open-output-16x16.png) Reveals the output directory in your file browser.
This will contain raw screenshots and other recorded data that later can be stitched.
- ![Stitch button](files/ui-gfx/stitch-16x16.png) Reveals the stitching tool
directory in your file browser.
- ![Stitch button](files/ui-gfx/stitch-16x16.png) Reveals the stitching tool directory in your file browser.
To stitch the final result, click ![Stitch button](files/ui-gfx/stitch-16x16.png) to open the directory of the stitching tool.
Start `stitch.exe` and proceed with the default values.
@ -79,6 +81,10 @@ After a few minutes the file `output.png` will be created.
- `Spiral`: Will capture the world in a spiral.
The center starting point of the spiral can either be your current viewport, the world center or some custom coordinates.
- `Animation`: Will capture an image sequence.
This will capture whatever you see frame by frame and stores it in the output folder by frame number.
You can't stitch the resulting images, but instead you can use something like ffmpeg to render the sequence into a video file.
### Advanced mod settings
- `World seed`: If non empty, this will set the next new game to this seed.
@ -146,8 +152,9 @@ The sliders are at their default values:
There is not a lot you can do about it:
- You can try to increase the usable address space of your `.../Noita/noita_dev.exe` or `.../Noita/noita.exe` with [Large Address Aware](https://www.techpowerup.com/forums/threads/large-address-aware.112556/) or a similar tool.
This will help with any crashes that are related to out of memory exceptions.
- ~~You can try to increase the usable address space of your `.../Noita/noita_dev.exe` or `.../Noita/noita.exe` with [Large Address Aware] or a similar tool.
This will help with any crashes that are related to out of memory exceptions.~~
`Large Address Aware` is already set in newer Noita builds.
- You can disable the replay recorder.
@ -189,25 +196,28 @@ This will cause fast moving objects to completely disappear, and slow moving obj
To disable median blending, use the stitcher with `Blend tile limit` set to 1.
This will cause the stitcher to only use the newest image tile for every resulting pixel.
### I always get the warning "The resolution changed"
The message is to be expected when you change the resolution in the Noita settings without restarting the game.
But it can also happen when you accidentally select the console window (using `noita_dev.exe`).
The mod uses the active/selected window of the Noita process for capturing, which in this case would make it take screenshots of the console.
This can be fixed by selecting the Noita window again, or by switching back and forth between console and the main Noita window.
## Additional information
## Viewing and hosting captures
The resulting stitched images are quite big.
You can read [this comment](https://github.com/Dadido3/noita-mapcap/issues/7#issuecomment-723591552) that addresses how you can view, convert or even self-host your images.
You can use [github.com/Dadido3/noita-mapcap-openseadragon] if you want to host a browser based viewer on your own web space.
If you want to make your captures available to a wider audience, you should check out the [github.com/acidflow-noita/noitamap] project, which aims to make maps of all game modes (including mods) available to the public.
## Acknowledgements
This mod uses the [LuaNXML](https://github.com/zatherz/luanxml) library by [Zatherz](https://github.com/zatherz).
This mod uses the [LuaNXML] library by [Zatherz].
Thanks to [Daniel Niccoli](https://github.com/danielniccoli) for figuring out how to change some in-game options by manipulating process memory.
## License
[MIT](LICENSE)
[github.com/acidflow-noita/noitamap]: https://github.com/acidflow-noita/noitamap
[github.com/Dadido3/noita-mapcap-openseadragon]: https://github.com/Dadido3/noita-mapcap-openseadragon
[Large Address Aware]: https://www.techpowerup.com/forums/threads/large-address-aware.112556/
[LuaNXML]: https://github.com/zatherz/luanxml
[map.runfast.stream]: https://map.runfast.stream
[Zatherz]: https://github.com/zatherz

View File

@ -1,8 +1,10 @@
; Copyright (c) 2019-2022 David Vogel
; Copyright (c) 2019-2024 David Vogel
;
; This software is released under the MIT License.
; https://opensource.org/licenses/MIT
EnableExplicit
UsePNGImageEncoder()
Declare Worker(*Dummy)
@ -15,44 +17,43 @@ Structure QueueElement
sy.i
EndStructure
; Source: https://www.purebasic.fr/english/viewtopic.php?f=13&t=29981&start=15
Procedure EnumWindowsProc(hWnd.l, *lParam.Long)
Protected lpProc.l
GetWindowThreadProcessId_(hWnd, @lpProc)
If *lParam\l = lpProc ; Check if current window's processID matches
*lParam\l = hWnd ; Replace processID in the param With the hwnd As result
ProcedureReturn #False ; Return false to stop iterating
Structure GLViewportDims
x.i
y.i
width.i
height.i
EndStructure
Structure WorkerInfo
workerNumber.i
EndStructure
#Workers = 8
; Returns the size of the main OpenGL rendering output.
ProcedureDLL GetGLViewportSize(*dims.GLViewportDims)
If Not *dims
ProcedureReturn #False
EndIf
glGetIntegerv_(#GL_VIEWPORT, *dims)
ProcedureReturn #True
EndProcedure
; Source: https://www.purebasic.fr/english/viewtopic.php?f=13&t=29981&start=15
; Returns the first window associated with the given process handle
Procedure GetProcHwnd()
Protected pID.l = GetCurrentProcessId_()
Protected tempParam.l = pID
EnumWindows_(@EnumWindowsProc(), @tempParam)
If tempParam = pID ; Check if anything was found
ProcedureReturn #Null
EndIf
ProcedureReturn tempParam ; This is a valid hWnd at this point
EndProcedure
; Get the client rectangle of the "Main" window of this process in screen coordinates
; Returns the size of the main OpenGL rendering output as a windows RECT.
ProcedureDLL GetRect(*rect.RECT)
Protected hWnd.l = GetProcHwnd()
If Not hWnd
ProcedureReturn #False
EndIf
If Not *rect
ProcedureReturn #False
EndIf
GetClientRect_(hWnd, *rect)
Protected dims.GLViewportDims
glGetIntegerv_(#GL_VIEWPORT, dims)
; A RECT consists basically of two POINT structures
ClientToScreen_(hWnd, @*rect\left)
ClientToScreen_(hWnd, @*rect\Right)
*rect\left = dims\x
*rect\top = dims\y
*rect\right = dims\x + dims\width
*rect\bottom = dims\y + dims\height
ProcedureReturn #True
EndProcedure
@ -64,13 +65,16 @@ ProcedureDLL AttachProcess(Instance)
CreateDirectory("mods/noita-mapcap/output/")
For i = 1 To 6
CreateThread(@Worker(), #Null)
Static Dim WorkerInfos.WorkerInfo(#Workers-1)
Protected i
For i = 0 To #Workers-1
WorkerInfos(i)\workerNumber = i
CreateThread(@Worker(), @WorkerInfos(i))
Next
EndProcedure
Procedure Worker(*Dummy)
Protected img, x, y
Procedure Worker(*workerInfo.WorkerInfo)
Protected img, x, y, sx, sy
Repeat
WaitSemaphore(Semaphore)
@ -84,66 +88,65 @@ Procedure Worker(*Dummy)
sy = Queue()\sy
DeleteElement(Queue())
UnlockMutex(Mutex)
If sx > 0 And sy > 0
ResizeImage(img, sx, sy)
EndIf
SaveImage(img, "mods/noita-mapcap/output/" + x + "," + y + ".png", #PB_ImagePlugin_PNG)
;SaveImage(img, "" + x + "," + y + ".png", #PB_ImagePlugin_PNG) ; Test
; Save image temporary, and only move it once it's fully exported.
; This prevents images getting corrupted when the main process crashes.
If SaveImage(img, "mods/noita-mapcap/output/worker_" + *workerInfo\workerNumber + ".tmp", #PB_ImagePlugin_PNG)
RenameFile("mods/noita-mapcap/output/worker_" + *workerInfo\workerNumber + ".tmp", "mods/noita-mapcap/output/" + x + "," + y + ".png")
; We can't really do anything when either SaveImage or RenameFile fails, so just silently fail.
EndIf
FreeImage(img)
ForEver
EndProcedure
; Takes a screenshot of the client area of this process' active window.
; The portion of the client area that is captured is described by capRect, which is in window coordinates and relative to the client area.
; The portion of the client area that is captured is described by capRect, which is in viewport coordinates.
; x and y defines the top left position of the captured rectangle in scaled world coordinates. The scale depends on the window to world pixel ratio.
; sx and sy defines the final dimensions that the screenshot will be resized to. No resize will happen if set to 0.
ProcedureDLL Capture(*capRect.RECT, x.l, y.l, sx.l, sy.l)
Protected hWnd.l = GetProcHwnd()
If Not hWnd
Protected viewportRect.RECT
If Not GetRect(@viewportRect)
ProcedureReturn #False
EndIf
Protected rect.RECT
If Not GetRect(@rect)
ProcedureReturn #False
EndIf
; Limit the desired capture area to the actual client area of the window.
Protected imageID, hDC, *pixelBuffer
; Limit the desired capture area to the actual client area of the viewport.
If *capRect\left < 0 : *capRect\left = 0 : EndIf
If *capRect\right > rect\right-rect\left : *capRect\right = rect\right-rect\left : EndIf
If *capRect\top < 0 : *capRect\top = 0 : EndIf
If *capRect\bottom > rect\bottom-rect\top : *capRect\bottom = rect\bottom-rect\top : EndIf
If *capRect\right < *capRect\left : *capRect\right = *capRect\left : EndIf
If *capRect\bottom < *capRect\top : *capRect\bottom = *capRect\top : EndIf
If *capRect\right > viewportRect\right : *capRect\right = viewportRect\right : EndIf
If *capRect\bottom > viewportRect\bottom : *capRect\bottom = viewportRect\bottom : EndIf
imageID = CreateImage(#PB_Any, *capRect\right-*capRect\left, *capRect\bottom-*capRect\top)
Protected capWidth = *capRect\right - *capRect\left
Protected capHeight = *capRect\bottom - *capRect\top
imageID = CreateImage(#PB_Any, capWidth, capHeight)
If Not imageID
ProcedureReturn #False
EndIf
; Get DC of window.
windowDC = GetDC_(hWnd)
If Not windowDC
FreeImage(imageID)
ProcedureReturn #False
EndIf
hDC = StartDrawing(ImageOutput(imageID))
If Not hDC
ReleaseDC_(hWnd, windowDC)
FreeImage(imageID)
ProcedureReturn #False
EndIf
If Not BitBlt_(hDC, 0, 0, *capRect\right-*capRect\left, *capRect\bottom-*capRect\top, windowDC, *capRect\left, *capRect\top, #SRCCOPY) ; After some time BitBlt will fail, no idea why. Also, that's moments before noita crashes.
StopDrawing()
ReleaseDC_(hWnd, windowDC)
FreeImage(imageID)
ProcedureReturn #False
EndIf
StopDrawing()
ReleaseDC_(hWnd, windowDC)
*pixelBuffer = DrawingBuffer()
glReadPixels_(*capRect\left, *capRect\top, capWidth, capHeight, #GL_BGR_EXT, #GL_UNSIGNED_BYTE, *pixelBuffer)
If glGetError_() <> #GL_NO_ERROR
StopDrawing()
FreeImage(imageID)
ProcedureReturn #False
EndIf
StopDrawing()
LockMutex(Mutex)
; Check if the queue has too many elements, if so, wait. (Emulate go's channels)
@ -173,13 +176,14 @@ EndProcedure
;Capture(123, 123)
;Delay(1000)
; IDE Options = PureBasic 6.00 LTS (Windows - x64)
; IDE Options = PureBasic 6.04 LTS (Windows - x64)
; ExecutableFormat = Shared dll
; CursorPosition = 94
; FirstLine = 39
; Folding = --
; CursorPosition = 99
; FirstLine = 72
; Folding = -
; Optimizer
; EnableThread
; EnableXP
; Executable = capture.dll
; Compiler = PureBasic 6.00 LTS (Windows - x86)
; DisableDebugger
; Compiler = PureBasic 6.04 LTS - C Backend (Windows - x86)

Binary file not shown.

View File

@ -36,18 +36,20 @@ example list of files:
If set to 1, only the newest tile will be used for any resulting pixel.
Use 1 to prevent ghosting and blurry objects.
- `input string`
The source path of the image tiles to be stitched. Defaults to "./..//..//output")
The source path of the image tiles to be stitched. Defaults to "./..//..//output"
- `entities string`
The path to the `entities.json` file. This contains Noita specific entity data. Defaults to "./../../output/entities.json".
- `player-path string`
The path to the player-path.json file. This contains the tracked path of the player. Defaults to "./../../output/player-path.json".
- `output string`
The path and filename of the resulting stitched image. Defaults to "output.png".
Supported formats/file extensions: `.png`, `.jpg`, `.dzi`.
Supported formats/file extensions: `.png`, `.webp`, `.jpg`, `.dzi`.
- `dzi-tile-size`
The size of the resulting deep zoom image (DZI) tiles in pixels. Defaults to 512.
- `dzi-tile-overlap`
TThe number of additional pixels around every deep zoom image (DZI) tile in pixels. Defaults to 2.
The number of additional pixels around every deep zoom image (DZI) tile. Defaults to 2.
- `webp-level`
Compression level of WebP files, from 0 (fast) to 9 (slow, best compression). Defaults to 8.
- `xmax int`
Right bound of the output rectangle. This coordinate is not included in the output.
- `xmin int`

View File

@ -1,4 +1,4 @@
// Copyright (c) 2022 David Vogel
// Copyright (c) 2022-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -8,6 +8,7 @@ package main
import (
"image"
"image/color"
"image/draw"
"math"
"sort"
)
@ -106,7 +107,7 @@ func (b BlendMethodVoronoi) Draw(tiles []*ImageTile, destImage *image.RGBA) {
images = append(images, tile.GetImage())
}
// Create arrays to be reused every pixel.
// Create color variables reused every pixel.
var col color.RGBA
var centerDistSqrMin int
@ -147,3 +148,17 @@ func (b BlendMethodVoronoi) Draw(tiles []*ImageTile, destImage *image.RGBA) {
}
}
}
// BlendMethodFast just draws all tiles into the destination image.
// No mixing is done, and this is very fast when there is no or minimal tile overlap.
type BlendMethodFast struct{}
// Draw implements the StitchedImageBlendMethod interface.
func (b BlendMethodFast) Draw(tiles []*ImageTile, destImage *image.RGBA) {
for _, tile := range tiles {
if image := tile.GetImage(); image != nil {
bounds := image.Bounds()
draw.Draw(destImage, bounds, image, bounds.Min, draw.Src)
}
}
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2023 David Vogel
// Copyright (c) 2023-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -12,9 +12,13 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/cheggaaa/pb/v3"
)
type DZI struct {
@ -35,7 +39,7 @@ func NewDZI(stitchedImage *StitchedImage, dziTileSize, dziOverlap int) DZI {
dzi := DZI{
stitchedImage: stitchedImage,
fileExtension: ".png",
fileExtension: ".webp",
overlap: dziOverlap,
tileSize: dziTileSize,
@ -78,24 +82,70 @@ func (d DZI) ExportDZIDescriptor(outputPath string) error {
Width string
Height string
}
TopLeft struct {
X string
Y string
}
}
}
dziDescriptor.Image.XMLNS = "http://schemas.microsoft.com/deepzoom/2008"
dziDescriptor.Image.Format = "png"
dziDescriptor.Image.Format = "webp"
dziDescriptor.Image.Overlap = strconv.Itoa(d.overlap)
dziDescriptor.Image.TileSize = strconv.Itoa(d.tileSize)
dziDescriptor.Image.Size.Width = strconv.Itoa(d.stitchedImage.bounds.Dx())
dziDescriptor.Image.Size.Height = strconv.Itoa(d.stitchedImage.bounds.Dy())
dziDescriptor.Image.TopLeft.X = strconv.Itoa(d.stitchedImage.bounds.Min.X)
dziDescriptor.Image.TopLeft.Y = strconv.Itoa(d.stitchedImage.bounds.Min.Y)
jsonEnc := json.NewEncoder(f)
return jsonEnc.Encode(dziDescriptor)
}
// ExportDZITiles exports the single image tiles for every zoom level.
func (d DZI) ExportDZITiles(outputDir string) error {
func (d DZI) ExportDZITiles(outputDir string, bar *pb.ProgressBar, webPLevel int) error {
log.Printf("Creating DZI tiles in %q.", outputDir)
const scaleDivider = 2
var exportedTiles atomic.Int64
// If there is a progress bar, start a goroutine that regularly updates it.
// We will base that on the number of exported tiles.
if bar != nil {
// Count final number of tiles.
bounds := d.stitchedImage.bounds
var finalTiles int64
for zoomLevel := d.maxZoomLevel; zoomLevel >= 0; zoomLevel-- {
for iY := 0; iY <= (bounds.Dy()-1)/d.tileSize; iY++ {
for iX := 0; iX <= (bounds.Dx()-1)/d.tileSize; iX++ {
finalTiles++
}
}
bounds = image.Rect(DivideFloor(bounds.Min.X, scaleDivider), DivideFloor(bounds.Min.Y, scaleDivider), DivideCeil(bounds.Max.X, scaleDivider), DivideCeil(bounds.Max.Y, scaleDivider))
}
bar.SetRefreshRate(250 * time.Millisecond).SetTotal(finalTiles).Start()
done := make(chan struct{})
defer func() {
done <- struct{}{}
bar.SetCurrent(bar.Total()).Finish()
}()
go func() {
ticker := time.NewTicker(250 * time.Millisecond)
for {
select {
case <-done:
return
case <-ticker.C:
bar.SetCurrent(exportedTiles.Load())
}
}
}()
}
// Start with the highest zoom level (Where every world pixel is exactly mapped into one image pixel).
// Generate all tiles for this level, and then stitch another image (scaled down by a factor of 2) based on the previously generated tiles.
// Repeat this process until we have generated level 0.
@ -114,6 +164,7 @@ func (d DZI) ExportDZITiles(outputDir string) error {
imageTiles := ImageTiles{}
// Export tiles.
lg := NewLimitGroup(runtime.NumCPU())
for iY := 0; iY <= (stitchedImage.bounds.Dy()-1)/d.tileSize; iY++ {
for iX := 0; iX <= (stitchedImage.bounds.Dx()-1)/d.tileSize; iX++ {
rect := image.Rect(iX*d.tileSize, iY*d.tileSize, iX*d.tileSize+d.tileSize, iY*d.tileSize+d.tileSize)
@ -121,11 +172,16 @@ func (d DZI) ExportDZITiles(outputDir string) error {
rect = rect.Inset(-d.overlap)
img := stitchedImage.SubStitchedImage(rect)
filePath := filepath.Join(levelBasePath, fmt.Sprintf("%d_%d%s", iX, iY, d.fileExtension))
if err := exportPNGSilent(img, filePath); err != nil {
return fmt.Errorf("failed to export PNG: %w", err)
}
scaleDivider := 2
lg.Add(1)
go func() {
defer lg.Done()
if err := exportWebP(img, filePath, webPLevel); err != nil {
log.Printf("Failed to export WebP: %v", err)
}
exportedTiles.Add(1)
}()
imageTiles = append(imageTiles, ImageTile{
fileName: filePath,
modTime: time.Now(),
@ -137,11 +193,12 @@ func (d DZI) ExportDZITiles(outputDir string) error {
})
}
}
lg.Wait()
// Create new stitched image from the previously exported tiles.
// The tiles are already created in a way, that they are scaled down by a factor of 2.
var err error
stitchedImage, err = NewStitchedImage(imageTiles, imageTiles.Bounds(), BlendMethodMedian{BlendTileLimit: 0}, 128, nil)
stitchedImage, err = NewStitchedImage(imageTiles, imageTiles.Bounds(), BlendMethodFast{}, 128, nil)
if err != nil {
return fmt.Errorf("failed to run NewStitchedImage(): %w", err)
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2023 David Vogel
// Copyright (c) 2023-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -10,9 +10,11 @@ import (
"os"
"path/filepath"
"strings"
"github.com/cheggaaa/pb/v3"
)
func exportDZI(stitchedImage *StitchedImage, outputPath string, dziTileSize, dziOverlap int) error {
func exportDZIStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar, dziTileSize, dziOverlap int, webPLevel int) error {
descriptorPath := outputPath
extension := filepath.Ext(outputPath)
outputTilesPath := strings.TrimSuffix(outputPath, extension) + "_files"
@ -30,7 +32,7 @@ func exportDZI(stitchedImage *StitchedImage, outputPath string, dziTileSize, dzi
}
// Export DZI tiles.
if err := dzi.ExportDZITiles(outputTilesPath); err != nil {
if err := dzi.ExportDZITiles(outputTilesPath, bar, webPLevel); err != nil {
return fmt.Errorf("failed to export DZI tiles: %w", err)
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2023 David Vogel
// Copyright (c) 2023-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -11,15 +11,44 @@ import (
"image/jpeg"
"log"
"os"
"time"
"github.com/cheggaaa/pb/v3"
)
func exportJPEG(stitchedImage image.Image, outputPath string) error {
func exportJPEGStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar) error {
log.Printf("Creating output file %q.", outputPath)
return exportJPEGSilent(stitchedImage, outputPath)
// If there is a progress bar, start a goroutine that regularly updates it.
// We will base the progress on the number of pixels read from the stitched image.
if bar != nil {
_, max := stitchedImage.Progress()
bar.SetRefreshRate(250 * time.Millisecond).SetTotal(int64(max)).Start()
done := make(chan struct{})
defer func() {
done <- struct{}{}
bar.SetCurrent(bar.Total()).Finish()
}()
go func() {
ticker := time.NewTicker(250 * time.Millisecond)
for {
select {
case <-done:
return
case <-ticker.C:
value, max := stitchedImage.Progress()
bar.SetCurrent(int64(value)).SetTotal(int64(max))
}
}
}()
}
return exportJPEG(stitchedImage, outputPath)
}
func exportJPEGSilent(stitchedImage image.Image, outputPath string) error {
func exportJPEG(img image.Image, outputPath string) error {
f, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
@ -30,7 +59,7 @@ func exportJPEGSilent(stitchedImage image.Image, outputPath string) error {
Quality: 80,
}
if err := jpeg.Encode(f, stitchedImage, options); err != nil {
if err := jpeg.Encode(f, img, options); err != nil {
return fmt.Errorf("failed to encode image %q: %w", outputPath, err)
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2023 David Vogel
// Copyright (c) 2023-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -11,15 +11,44 @@ import (
"image/png"
"log"
"os"
"time"
"github.com/cheggaaa/pb/v3"
)
func exportPNG(stitchedImage image.Image, outputPath string) error {
func exportPNGStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar) error {
log.Printf("Creating output file %q.", outputPath)
return exportPNGSilent(stitchedImage, outputPath)
// If there is a progress bar, start a goroutine that regularly updates it.
// We will base the progress on the number of pixels read from the stitched image.
if bar != nil {
_, max := stitchedImage.Progress()
bar.SetRefreshRate(250 * time.Millisecond).SetTotal(int64(max)).Start()
done := make(chan struct{})
defer func() {
done <- struct{}{}
bar.SetCurrent(bar.Total()).Finish()
}()
go func() {
ticker := time.NewTicker(250 * time.Millisecond)
for {
select {
case <-done:
return
case <-ticker.C:
value, max := stitchedImage.Progress()
bar.SetCurrent(int64(value)).SetTotal(int64(max))
}
}
}()
}
return exportPNG(stitchedImage, outputPath)
}
func exportPNGSilent(stitchedImage image.Image, outputPath string) error {
func exportPNG(img image.Image, outputPath string) error {
f, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
@ -30,7 +59,7 @@ func exportPNGSilent(stitchedImage image.Image, outputPath string) error {
CompressionLevel: png.DefaultCompression,
}
if err := encoder.Encode(f, stitchedImage); err != nil {
if err := encoder.Encode(f, img); err != nil {
return fmt.Errorf("failed to encode image %q: %w", outputPath, err)
}

73
bin/stitch/export-webp.go Normal file
View File

@ -0,0 +1,73 @@
// Copyright (c) 2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
"log"
"os"
"time"
"github.com/Dadido3/go-libwebp/webp"
"github.com/cheggaaa/pb/v3"
)
func exportWebPStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar, webPLevel int) error {
log.Printf("Creating output file %q.", outputPath)
// If there is a progress bar, start a goroutine that regularly updates it.
// We will base the progress on the number of pixels read from the stitched image.
if bar != nil {
_, max := stitchedImage.Progress()
bar.SetRefreshRate(250 * time.Millisecond).SetTotal(int64(max)).Start()
done := make(chan struct{})
defer func() {
done <- struct{}{}
bar.SetCurrent(bar.Total()).Finish()
}()
go func() {
ticker := time.NewTicker(250 * time.Millisecond)
for {
select {
case <-done:
return
case <-ticker.C:
value, max := stitchedImage.Progress()
bar.SetCurrent(int64(value)).SetTotal(int64(max))
}
}
}()
}
return exportWebP(stitchedImage, outputPath, webPLevel)
}
func exportWebP(img image.Image, outputPath string, webPLevel int) error {
bounds := img.Bounds()
if bounds.Dx() > 16383 || bounds.Dy() > 16383 {
return fmt.Errorf("image size exceeds the maximum allowed size (16383) of a WebP image: %d x %d", bounds.Dx(), bounds.Dy())
}
f, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer f.Close()
webPConfig, err := webp.ConfigLosslessPreset(webPLevel)
if err != nil {
return fmt.Errorf("failed to create webP config: %v", err)
}
if err = webp.Encode(f, img, webPConfig); err != nil {
return fmt.Errorf("failed to encode image %q: %w", outputPath, err)
}
return nil
}

View File

@ -8,6 +8,7 @@ package main
import (
"fmt"
"image"
"image/draw"
_ "image/png"
"log"
"os"
@ -127,9 +128,16 @@ func (it *ImageTile) GetImage() *image.RGBA {
img = resize.Resize(uint(oldRect.Dx()), uint(oldRect.Dy()), img, resize.NearestNeighbor)
}
imgRGBA, ok := img.(*image.RGBA)
if !ok {
log.Printf("Expected an RGBA image for %q, got %T instead.", it.fileName, img)
var imgRGBA *image.RGBA
switch img := img.(type) {
case *image.RGBA:
imgRGBA = img
case *image.NRGBA:
bounds := img.Bounds()
imgRGBA = image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))
draw.Draw(imgRGBA, imgRGBA.Bounds(), img, bounds.Min, draw.Src)
default:
log.Printf("Expected an RGBA or NRGBA image for %q, got %T instead.", it.fileName, img)
return nil
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2019-2023 David Vogel
// Copyright (c) 2019-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -12,7 +12,6 @@ import (
"log"
"path/filepath"
"strings"
"sync"
"time"
"github.com/1lann/promptui"
@ -22,11 +21,12 @@ import (
var flagInputPath = flag.String("input", filepath.Join(".", "..", "..", "output"), "The source path of the image tiles to be stitched.")
var flagEntitiesInputPath = flag.String("entities", filepath.Join(".", "..", "..", "output", "entities.json"), "The path to the entities.json file.")
var flagPlayerPathInputPath = flag.String("player-path", filepath.Join(".", "..", "..", "output", "player-path.json"), "The path to the player-path.json file.")
var flagOutputPath = flag.String("output", filepath.Join(".", "output.png"), "The path and filename of the resulting stitched image. Supported formats/file extensions: `.png`, `.jpg`, `.dzi`.")
var flagOutputPath = flag.String("output", filepath.Join(".", "output.png"), "The path and filename of the resulting stitched image. Supported formats/file extensions: `.png`, `.webp`, `.jpg`, `.dzi`.")
var flagScaleDivider = flag.Int("divide", 1, "A downscaling factor. 2 will produce an image with half the side lengths.")
var flagBlendTileLimit = flag.Int("blend-tile-limit", 9, "Limits median blending to the n newest tiles by file modification time. If set to 0, all available tiles will be median blended.")
var flagDZITileSize = flag.Int("dzi-tile-size", 512, "The size of the resulting deep zoom image (DZI) tiles in pixels.")
var flagDZIOverlap = flag.Int("dzi-tile-overlap", 2, "The number of additional pixels around every deep zoom image (DZI) tile in pixels.")
var flagDZIOverlap = flag.Int("dzi-tile-overlap", 2, "The number of additional pixels around every deep zoom image (DZI) tile.")
var flagWebPLevel = flag.Int("webp-level", 8, "Compression level of WebP files, from 0 (fast) to 9 (slow, best compression).")
var flagXMin = flag.Int("xmin", 0, "Left bound of the output rectangle. This coordinate is included in the output.")
var flagYMin = flag.Int("ymin", 0, "Upper bound of the output rectangle. This coordinate is included in the output.")
var flagXMax = flag.Int("xmax", 0, "Right bound of the output rectangle. This coordinate is not included in the output.")
@ -287,11 +287,35 @@ func main() {
fmt.Sscanf(result, "%d", flagDZIOverlap)
}
startTime := time.Now()
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 && (fileExtension == ".dzi" || fileExtension == ".webp") {
prompt := promptui.Prompt{
Label: "Enter WebP compression level:",
Default: fmt.Sprint(*flagWebPLevel),
AllowEdit: true,
Validate: func(s string) error {
var num int
_, err := fmt.Sscanf(s, "%d", &num)
if err != nil {
return err
}
if int(num) < 0 {
return fmt.Errorf("level must be at least 0")
}
if int(num) > 9 {
return fmt.Errorf("level must not be larger than 9")
}
bar := pb.Full.New(0)
var wg sync.WaitGroup
done := make(chan struct{})
return nil
},
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
fmt.Sscanf(result, "%d", flagWebPLevel)
}
blendMethod := BlendMethodMedian{
BlendTileLimit: *flagBlendTileLimit, // Limit median blending to the n newest tiles by file modification time.
@ -301,49 +325,31 @@ func main() {
if err != nil {
log.Panicf("NewStitchedImage() failed: %v.", err)
}
_, max := stitchedImage.Progress()
bar.SetTotal(int64(max)).Start().SetRefreshRate(250 * time.Millisecond)
// Query progress and draw progress bar.
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(250 * time.Millisecond)
for {
select {
case <-done:
value, _ := stitchedImage.Progress()
bar.SetCurrent(int64(value))
bar.Finish()
return
case <-ticker.C:
value, _ := stitchedImage.Progress()
bar.SetCurrent(int64(value))
}
}
}()
bar := pb.Full.New(0)
switch fileExtension {
case ".png":
if err := exportPNG(stitchedImage, *flagOutputPath); err != nil {
if err := exportPNGStitchedImage(stitchedImage, *flagOutputPath, bar); err != nil {
log.Panicf("Export of PNG file failed: %v", err)
}
case ".jpg", ".jpeg":
if err := exportJPEG(stitchedImage, *flagOutputPath); err != nil {
if err := exportJPEGStitchedImage(stitchedImage, *flagOutputPath, bar); err != nil {
log.Panicf("Export of JPEG file failed: %v", err)
}
case ".webp":
if err := exportWebPStitchedImage(stitchedImage, *flagOutputPath, bar, *flagWebPLevel); err != nil {
log.Panicf("Export of WebP file failed: %v", err)
}
case ".dzi":
if err := exportDZI(stitchedImage, *flagOutputPath, *flagDZITileSize, *flagDZIOverlap); err != nil {
if err := exportDZIStitchedImage(stitchedImage, *flagOutputPath, bar, *flagDZITileSize, *flagDZIOverlap, *flagWebPLevel); err != nil {
log.Panicf("Export of DZI file failed: %v", err)
}
default:
log.Panicf("Unknown output format %q.", fileExtension)
}
done <- struct{}{}
wg.Wait()
log.Printf("Created output in %v.", time.Since(startTime))
log.Printf("Created output in %v.", time.Since(bar.StartTime()))
//fmt.Println("Press the enter key to terminate the console screen!")
//fmt.Scanln()

View File

@ -1,4 +1,4 @@
// Copyright (c) 2022-2023 David Vogel
// Copyright (c) 2022-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -8,6 +8,7 @@ package main
import (
"image"
"image/color"
"image/draw"
"runtime"
"sync"
)
@ -69,7 +70,9 @@ func (sic *StitchedImageCache) Regenerate() *image.RGBA {
si := sic.stitchedImage
// Create new image with default background color.
cacheImage := image.NewRGBA(sic.rect)
draw.Draw(cacheImage, cacheImage.Bounds(), &image.Uniform{colorBackground}, cacheImage.Bounds().Min, draw.Src)
// List of tiles that intersect with the to be generated cache image.
intersectingTiles := []*ImageTile{}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2022-2023 David Vogel
// Copyright (c) 2022-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -13,6 +13,10 @@ import (
"time"
)
// The default background color.
// We use a non transparent black.
var colorBackground = color.RGBA{0, 0, 0, 255}
// StitchedImageCacheGridSize defines the worker chunk size when the cache image is regenerated.
var StitchedImageCacheGridSize = 256
@ -116,7 +120,7 @@ func (si *StitchedImage) RGBAAt(x, y int) color.RGBA {
// Determine the cache rowIndex index.
rowIndex := (y + si.cacheRowYOffset) / si.cacheRowHeight
if rowIndex < 0 || rowIndex >= len(si.cacheRows) {
return color.RGBA{}
return colorBackground
}
// Check if we advanced/changed the row index.

View File

@ -1,4 +1,4 @@
// Copyright (c) 2023 David Vogel
// Copyright (c) 2023-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -29,7 +29,7 @@ func (s SubStitchedImage) At(x, y int) color.Color {
func (s SubStitchedImage) RGBAAt(x, y int) color.RGBA {
point := image.Point{X: x, Y: y}
if !point.In(s.bounds) {
return color.RGBA{}
return colorBackground
}
return s.StitchedImage.RGBAAt(x, y)

View File

@ -1,4 +1,4 @@
// Copyright (c) 2019-2022 David Vogel
// Copyright (c) 2019-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
@ -9,6 +9,7 @@ import (
"fmt"
"image"
"os"
"sync"
)
// QuickSelect returns the kth smallest element of the given unsorted list.
@ -96,3 +97,44 @@ func DivideCeil(a, b int) int {
return temp
}
// https://gist.github.com/cstockton/d611ced26bb6b4d3f7d4237abb8613c4
type LimitGroup struct {
wg sync.WaitGroup
mu *sync.Mutex
c *sync.Cond
l, n int
}
func NewLimitGroup(n int) *LimitGroup {
mu := new(sync.Mutex)
return &LimitGroup{
mu: mu,
c: sync.NewCond(mu),
l: n,
n: n,
}
}
func (lg *LimitGroup) Add(delta int) {
lg.mu.Lock()
defer lg.mu.Unlock()
if delta > lg.l {
panic(`LimitGroup: delta must not exceed limit`)
}
for lg.n < 1 {
lg.c.Wait()
}
lg.n -= delta
lg.wg.Add(delta)
}
func (lg *LimitGroup) Done() {
lg.mu.Lock()
defer lg.mu.Unlock()
lg.n++
lg.c.Signal()
lg.wg.Done()
}
func (lg *LimitGroup) Wait() { lg.wg.Wait() }

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2019-2023 David Vogel
-- Copyright (c) 2019-2024 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
@ -32,7 +32,7 @@ Capture.PlayerPathCapturingCtx = Capture.PlayerPathCapturingCtx or ProcessRunner
---Returns a capturing rectangle in window coordinates, and also the world coordinates for the same rectangle.
---The rectangle is sized and placed in a way that aligns as pixel perfect as possible with the world coordinates.
---@param pos Vec2|nil -- Position of the viewport center in world coordinates. If set to nil, the viewport center will be queried automatically.
---@param pos Vec2? -- Position of the viewport center in world coordinates. If set to nil, the viewport center will be queried automatically.
---@return Vec2 topLeftCapture
---@return Vec2 bottomRightCapture
---@return Vec2 topLeftWorld
@ -53,19 +53,20 @@ end
---This will block until all chunks in the virtual rectangle are loaded.
---
---Don't set `ensureLoaded` to true when `pos` is nil!
---@param pos Vec2|nil -- Position of the viewport center in world coordinates. If set to nil, the viewport will not be modified.
---@param ensureLoaded boolean|nil -- If true, the function will wait until all chunks in the virtual rectangle are loaded.
---@param dontOverwrite boolean|nil -- If true, the function will abort if there is already a file with the same coordinates.
---@param ctx ProcessRunnerCtx|nil -- The process runner context this runs in.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPixelScale)
---@param pos Vec2? -- Position of the viewport center in world coordinates. If set to nil, the viewport will not be modified.
---@param ensureLoaded boolean? -- If true, the function will wait until all chunks in the virtual rectangle are loaded.
---@param dontOverwrite boolean? -- If true, the function will abort if there is already a file with the same coordinates.
---@param ctx ProcessRunnerCtx? -- The process runner context this runs in.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
---@param captureDelay number? -- The number of additional frames to wait before a screen capture.
local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPixelScale, captureDelay)
if outputPixelScale == 0 or outputPixelScale == nil then
outputPixelScale = Coords:PixelScale()
end
local rectTopLeft, rectBottomRight = ScreenCapture.GetRect()
if Coords.WindowResolution ~= rectBottomRight - rectTopLeft then
error(string.format("window size seems to have changed from %s to %s", Coords.WindowResolution, rectBottomRight - rectTopLeft))
if Coords:InternalRectSize() ~= rectBottomRight - rectTopLeft then
error(string.format("internal rectangle size seems to have changed from %s to %s", Coords:InternalRectSize(), rectBottomRight - rectTopLeft))
end
local topLeftCapture, bottomRightCapture, topLeftWorld, bottomRightWorld = calculateCaptureRectangle(pos)
@ -79,10 +80,23 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
return
end
-- Reset the count for the "Waiting for x frames." message in the UI.
if ctx then ctx.state.WaitFrames = 0 end
-- Wait some additional frames.
-- We will shake the screen a little bit so that Noita generates/populates chunks.
if captureDelay and captureDelay > 0 then
for _ = 1, captureDelay do
if pos then CameraAPI.SetPos(pos + Vec2(math.random(-1, 1), math.random(-1, 1))) end
wait(0)
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 1 end
end
end
if pos then CameraAPI.SetPos(pos) end
if ensureLoaded then
local delayFrames = 0
if ctx then ctx.state.WaitFrames = delayFrames end
repeat
-- Prematurely stop capturing if that is requested by the context.
if ctx and ctx:IsStopping() then return end
@ -92,25 +106,43 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
if pos then CameraAPI.SetPos(pos + Vec2(math.random(-10, 10), math.random(-10, 10))) end
wait(0)
delayFrames = delayFrames + 1
if ctx then ctx.state.WaitFrames = delayFrames end
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 1 end
if pos then CameraAPI.SetPos(pos) end
end
if delayFrames > 600 then
-- Shaking wasn't enough, we will just move somewhere else an try again.
if pos then CameraAPI.SetPos(pos + Vec2(math.random(-4000, 4000), math.random(-4000, 4000))) end
wait(50)
delayFrames = delayFrames + 50
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 50 end
if pos then CameraAPI.SetPos(pos) end
wait(10)
delayFrames = delayFrames + 10
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 10 end
end
wait(0)
delayFrames = delayFrames + 1
if ctx then ctx.state.WaitFrames = delayFrames end
if ctx then ctx.state.WaitFrames = ctx.state.WaitFrames + 1 end
local topLeftBounds, bottomRightBounds = CameraAPI:Bounds()
until DoesWorldExistAt(topLeftBounds.x, topLeftBounds.y, bottomRightBounds.x, bottomRightBounds.y)
-- Chunks are loaded and will be drawn on the *next* frame.
end
if ctx then ctx.state.WaitFrames = 0 end
-- Suspend UI drawing for 1 frame.
UI:SuspendDrawing(1)
-- First we wait one frame for the current state to be drawn.
wait(0)
-- Fetch coordinates again, as they may have changed.
-- At this point the needed frame is fully drawn, but the framebuffers are swapped.
-- Recalculate capture position and rectangle if we are not forcing any capture position.
-- We are in the `OnWorldPreUpdate` hook, this means that `CameraAPI.GetPos` return the position of the last frame.
if not pos then
topLeftCapture, bottomRightCapture, topLeftWorld, bottomRightWorld = calculateCaptureRectangle(pos)
if outputPixelScale > 0 then
@ -120,6 +152,10 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
end
end
-- Wait another frame.
-- After this `wait` the framebuffer will be swapped again, and we can grab the correct frame.
wait(0)
-- The top left world position needs to be upscaled by the pixel scale.
-- Otherwise it's not possible to stitch the images correctly.
if not ScreenCapture.Capture(topLeftCapture, bottomRightCapture, outputTopLeft, (bottomRightWorld - topLeftWorld) * outputPixelScale) then
@ -130,6 +166,37 @@ local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPi
MonitorStandby.ResetTimer()
end
---Captures a screenshot of the current viewport.
---This is used to capture animations, therefore the resulting image may not be suitable for stitching.
---@param outputPixelScale number? The resulting image pixel to world pixel ratio.
---@param frameNumber integer The frame number of the animation.
local function captureScreenshotAnimation(outputPixelScale, frameNumber)
if outputPixelScale == 0 or outputPixelScale == nil then
outputPixelScale = Coords:PixelScale()
end
local rectTopLeft, rectBottomRight = ScreenCapture.GetRect()
if not rectTopLeft or not rectBottomRight then
error(string.format("couldn't determine capturing rectangle"))
end
if Coords:InternalRectSize() ~= rectBottomRight - rectTopLeft then
error(string.format("internal rectangle size seems to have changed from %s to %s", Coords:InternalRectSize(), rectBottomRight - rectTopLeft))
end
local topLeftWorld, bottomRightWorld = Coords:ToWorld(rectTopLeft), Coords:ToWorld(rectBottomRight)
---We will use this to get our fame number into the filename.
---@type Vec2
local outputTopLeft = Vec2(frameNumber, 0)
if not ScreenCapture.Capture(rectTopLeft, rectBottomRight, outputTopLeft, (bottomRightWorld - topLeftWorld) * outputPixelScale) then
error(string.format("failed to capture screenshot"))
end
-- Reset monitor and PC standby every screenshot.
MonitorStandby.ResetTimer()
end
---Map capture process runner context error handler callback. Just rolls off the tongue.
---@param err string
---@param scope "init"|"do"|"end"
@ -142,8 +209,9 @@ end
---Use `Capture.MapCapturingCtx` to stop, control or view the progress.
---@param origin Vec2 -- Center of the spiral in world pixels.
---@param captureGridSize number -- The grid size in world pixels.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale)
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
---@param captureDelay number? -- The number of additional frames to wait before a screen capture.
function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale, captureDelay)
-- Create file that signals that there are files in the output directory.
local file = io.open("mods/noita-mapcap/output/nonempty", "a")
@ -167,23 +235,23 @@ function Capture:StartCapturingSpiral(origin, captureGridSize, outputPixelScale)
repeat
-- +x
for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale)
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
pos:Add(Vec2(captureGridSize, 0))
end
-- +y
for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale)
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
pos:Add(Vec2(0, captureGridSize))
end
i = i + 1
-- -x
for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale)
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
pos:Add(Vec2(-captureGridSize, 0))
end
-- -y
for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale)
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
pos:Add(Vec2(0, -captureGridSize))
end
i = i + 1
@ -203,24 +271,29 @@ end
---Starts the capturing process of the given area using a hilbert curve.
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
---@param topLeft Vec2 -- Top left of the to be captured rectangle.
---@param bottomRight Vec2 -- Non included bottom left of the to be captured rectangle.
---@param bottomRight Vec2 -- Non inclusive bottom right coordinate of the to be captured rectangle.
---@param captureGridSize number -- The grid size in world pixels.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingAreaHilbert(topLeft, bottomRight, captureGridSize, outputPixelScale)
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
---@param captureDelay number? -- The number of additional frames to wait before a screen capture.
function Capture:StartCapturingAreaHilbert(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
-- Create file that signals that there are files in the output directory.
local file = io.open("mods/noita-mapcap/output/nonempty", "a")
if file ~= nil then file:close() end
---The rectangle in grid coordinates.
-- The capture offset which is needed to center the grid cells in the viewport.
local captureOffset = Vec2(captureGridSize / 2, captureGridSize / 2)
-- Get the extended capture rectangle that encloses all grid cells that need to be included in the capture.
-- In this case we only need to extend the capture area by the valid rendering rectangle.
local validTopLeft, validBottomRight = Coords:ValidRenderingRect()
local validTopLeftWorld, validBottomRightWorld = Coords:ToWorld(validTopLeft, topLeft + captureOffset), Coords:ToWorld(validBottomRight, bottomRight + captureOffset)
---The capture rectangle in grid coordinates.
---@type Vec2, Vec2
local gridTopLeft, gridBottomRight = (topLeft / captureGridSize):Rounded("floor"), (bottomRight / captureGridSize):Rounded("floor")
local gridTopLeft, gridBottomRight = (validTopLeftWorld / captureGridSize):Rounded("floor"), ((validBottomRightWorld) / captureGridSize):Rounded("ceil") - Vec2(1, 1)
-- Handle edge cases.
if topLeft.x == bottomRight.x then gridBottomRight.x = gridTopLeft.x end
if topLeft.y == bottomRight.y then gridBottomRight.y = gridTopLeft.y end
---Size of the rectangle in grid coordinates.
---Size of the rectangle in grid cells.
---@type Vec2
local gridSize = gridBottomRight - gridTopLeft
@ -249,8 +322,8 @@ function Capture:StartCapturingAreaHilbert(topLeft, bottomRight, captureGridSize
---Position in world coordinates.
---@type Vec2
local pos = (hilbertPos + gridTopLeft) * captureGridSize
pos:Add(Vec2(captureGridSize / 2, captureGridSize / 2)) -- Move to center of grid cell.
captureScreenshot(pos, true, true, ctx, outputPixelScale)
pos:Add(captureOffset) -- Move to center of grid cell.
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
ctx.state.Current = ctx.state.Current + 1
end
@ -271,20 +344,29 @@ end
---Starts the capturing process of the given area by scanning from left to right, and top to bottom.
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
---@param topLeft Vec2 -- Top left of the to be captured rectangle.
---@param bottomRight Vec2 -- Non included bottom left of the to be captured rectangle.
---@param bottomRight Vec2 -- Non inclusive bottom right coordinate of the to be captured rectangle.
---@param captureGridSize number -- The grid size in world pixels.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale)
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
---@param captureDelay number? -- The number of additional frames to wait before a screen capture.
function Capture:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
-- Create file that signals that there are files in the output directory.
local file = io.open("mods/noita-mapcap/output/nonempty", "a")
if file ~= nil then file:close() end
---The rectangle in grid coordinates.
---@type Vec2, Vec2
local gridTopLeft, gridBottomRight = (topLeft / captureGridSize):Rounded("floor"), (bottomRight / captureGridSize):Rounded("floor")
-- The capture offset which is needed to center the grid cells in the viewport.
local captureOffset = Vec2(captureGridSize / 2, captureGridSize / 2)
---Size of the rectangle in grid coordinates.
-- Get the extended capture rectangle that encloses all grid cells that need to be included in the capture.
-- In this case we only need to extend the capture area by the valid rendering rectangle.
local validTopLeft, validBottomRight = Coords:ValidRenderingRect()
local validTopLeftWorld, validBottomRightWorld = Coords:ToWorld(validTopLeft, topLeft + captureOffset), Coords:ToWorld(validBottomRight, bottomRight + captureOffset)
---The capture rectangle in grid coordinates.
---@type Vec2, Vec2
local gridTopLeft, gridBottomRight = (validTopLeftWorld / captureGridSize):Rounded("floor"), ((validBottomRightWorld) / captureGridSize):Rounded("ceil") - Vec2(1, 1)
---Size of the rectangle in grid cells.
---@type Vec2
local gridSize = gridBottomRight - gridTopLeft
@ -306,8 +388,8 @@ function Capture:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, o
---Position in world coordinates.
---@type Vec2
local pos = gridPos * captureGridSize
pos:Add(Vec2(captureGridSize / 2, captureGridSize / 2)) -- Move to center of grid cell.
captureScreenshot(pos, true, true, ctx, outputPixelScale)
pos:Add(captureOffset) -- Move to center of grid cell.
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
ctx.state.Current = ctx.state.Current + 1
end
end
@ -325,7 +407,7 @@ end
---Starts the live capturing process.
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingLive(outputPixelScale)
---Queries the mod settings for the live capture parameters.
@ -363,7 +445,7 @@ function Capture:StartCapturingLive(outputPixelScale)
if oldPos then distanceSqr = CameraAPI.GetPos():DistanceSqr(oldPos) else distanceSqr = math.huge end
until ctx:IsStopping() or ((delayFrames >= interval or distanceSqr >= maxDistanceSqr) and distanceSqr >= minDistanceSqr)
captureScreenshot(nil, false, false, ctx, outputPixelScale)
captureScreenshot(nil, false, false, ctx, outputPixelScale, nil)
oldPos = CameraAPI.GetPos()
until ctx:IsStopping()
end
@ -379,7 +461,7 @@ function Capture:StartCapturingLive(outputPixelScale)
end
---Gathers all entities on the screen (around x, y within radius), serializes them, appends them into entityFile and/or modifies those entities.
---@param file file*|nil
---@param file file*?
---@param modify boolean
---@param x number
---@param y number
@ -502,7 +584,7 @@ local function captureModifyEntities(file, modify, x, y, radius)
end
---
---@return file*|nil
---@return file*?
local function createOrOpenEntityCaptureFile()
-- Make sure the file exists.
local file = io.open("mods/noita-mapcap/output/entities.json", "a")
@ -562,7 +644,7 @@ function Capture:StartCapturingEntities(store, modify)
end
---Writes the current player position and other stats onto disk.
---@param file file*|nil
---@param file file*?
---@param pos Vec2
---@param oldPos Vec2
---@param hp number
@ -595,7 +677,7 @@ local function writePlayerPathEntry(file, pos, oldPos, hp, maxHP, polymorphed)
end
---
---@return file*|nil
---@return file*?
local function createOrOpenPlayerPathCaptureFile()
-- Make sure the file exists.
local file = io.open("mods/noita-mapcap/output/player-path.json", "a")
@ -610,8 +692,8 @@ end
---Starts capturing the player path.
---Use `Capture.PlayerPathCapturingCtx` to stop, control or view the progress.
---@param interval integer|nil -- Wait time between captures in frames.
---@param outputPixelScale number|nil -- The resulting image pixel to world pixel ratio.
---@param interval integer? -- Wait time between captures in frames.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingPlayerPath(interval, outputPixelScale)
interval = interval or 20
@ -639,7 +721,7 @@ function Capture:StartCapturingPlayerPath(interval, outputPixelScale)
-- It seems that the entity can still be found by the tag, but its components/values can't be accessed anymore.
-- Solution: Don't do that.
---@type NoitaEntity|nil
---@type NoitaEntity?
local playerEntity
-- Try to find the regular player entity.
@ -699,6 +781,56 @@ function Capture:StartCapturingPlayerPath(interval, outputPixelScale)
self.PlayerPathCapturingCtx:Run(handleInit, handleDo, handleEnd, handleErr)
end
---Starts to capture an animation.
---This stores sequences of images that can't be stitched, but can be rendered into a video instead.
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingAnimation(outputPixelScale)
---Queries the mod settings for the live capture parameters.
---@return integer interval -- The interval length in frames.
local function querySettings()
local interval = 1--tonumber(ModSettingGet("noita-mapcap.live-interval")) or 30
return interval
end
-- Create file that signals that there are files in the output directory.
local file = io.open("mods/noita-mapcap/output/nonempty", "a")
if file ~= nil then file:close() end
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo(ctx)
Modification.SetCameraFree(false)
local frame = 0
repeat
local interval = querySettings()
-- Wait until we are allowed to take a new screenshot.
local delayFrames = 0
repeat
wait(0)
delayFrames = delayFrames + 1
until ctx:IsStopping() or delayFrames >= interval
captureScreenshotAnimation(outputPixelScale, frame)
frame = frame + 1
until ctx:IsStopping()
end
---Process end callback.
---@param ctx ProcessRunnerCtx
local function handleEnd(ctx)
Modification.SetCameraFree()
end
-- Run process, if there is no other running right now.
self.MapCapturingCtx:Run(nil, handleDo, handleEnd, mapCapturingCtxErrHandler)
end
---Starts the capturing process based on user/mod settings.
function Capture:StartCapturing()
Message:CatchException("Capture:StartCapturing", function()
@ -706,21 +838,26 @@ function Capture:StartCapturing()
local mode = ModSettingGet("noita-mapcap.capture-mode")
local outputPixelScale = ModSettingGet("noita-mapcap.pixel-scale")
local captureGridSize = tonumber(ModSettingGet("noita-mapcap.grid-size"))
local captureDelay = tonumber(ModSettingGet("noita-mapcap.capture-delay"))
if mode == "live" then
self:StartCapturingLive(outputPixelScale)
self:StartCapturingPlayerPath(5, outputPixelScale) -- Capture player path with an interval of 5 frames.
elseif mode == "animation" then
self:StartCapturingAnimation(outputPixelScale)
elseif mode == "area" then
local area = ModSettingGet("noita-mapcap.area")
if area == "custom" then
local topLeft = Vec2(ModSettingGet("noita-mapcap.area-top-left"))
local bottomRight = Vec2(ModSettingGet("noita-mapcap.area-bottom-right"))
self:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale)
self:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
else
local predefinedArea = Config.CaptureArea[area]
if predefinedArea then
self:StartCapturingAreaScan(predefinedArea.TopLeft, predefinedArea.BottomRight, captureGridSize, outputPixelScale)
---@type fun():Vec2, Vec2
local predefinedAreaFunction = Config.CaptureArea[area]
if predefinedAreaFunction then
local topLeft, bottomRight = predefinedAreaFunction()
self:StartCapturingAreaScan(topLeft, bottomRight, captureGridSize, outputPixelScale, captureDelay)
else
Message:ShowRuntimeError("PredefinedArea", string.format("Unknown predefined capturing area %q", tostring(area)))
end
@ -729,13 +866,13 @@ function Capture:StartCapturing()
local origin = ModSettingGet("noita-mapcap.capture-mode-spiral-origin")
if origin == "custom" then
local originVec = Vec2(ModSettingGet("noita-mapcap.capture-mode-spiral-origin-vector"))
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay)
elseif origin == "0" then
local originVec = Vec2(0, 0)
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay)
elseif origin == "current" then
local originVec = CameraAPI:GetPos()
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale)
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay)
else
Message:ShowRuntimeError("SpiralOrigin", string.format("Unknown spiral origin %q", tostring(origin)))
end

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2019-2022 David Vogel
-- Copyright (c) 2019-2024 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
@ -53,8 +53,8 @@ function Check:Regular(interval)
local topLeft, bottomRight = ScreenCap.GetRect() -- Actual window client area.
if topLeft and bottomRight then
local actual = bottomRight - topLeft
if actual ~= Coords.WindowResolution then
Message:ShowWrongResolution(Modification.AutoSet, string.format("Old window resolution is %s. Current resolution is %s.", Coords.WindowResolution, actual))
if actual ~= Coords:InternalRectSize() then
Message:ShowWrongResolution(Modification.AutoSet, string.format("Internal rectangle size is %s. Current resolution is %s.", Coords:InternalRectSize(), actual))
end
else
Message:ShowRuntimeError("GetRect", "Couldn't determine window resolution.")
@ -86,6 +86,12 @@ function Check:Regular(interval)
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Screen shake intensity is %s, expected %s.", self.StartupConfig.screenshake_intensity, expected))
end
end
if config["application_rendered_cursor"] then
local expected = config.application_rendered_cursor
if expected ~= self.StartupConfig.application_rendered_cursor then
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Application rendered cursor is %s, expected %s.", self.StartupConfig.application_rendered_cursor, expected))
end
end
-- Magic numbers stuff doesn't need a forced restart, just a normal restart by the user.
if magic["VIRTUAL_RESOLUTION_X"] and magic["VIRTUAL_RESOLUTION_Y"] then
@ -114,7 +120,7 @@ function Check:Regular(interval)
-- This is not perfect, as it doesn't take rounding and cropping into account, so the actual captured area may be a few pixels smaller.
local mode = ModSettingGet("noita-mapcap.capture-mode")
local captureGridSize = tonumber(ModSettingGet("noita-mapcap.grid-size"))
if mode ~= "live" and (Coords.VirtualResolution.x < captureGridSize or Coords.VirtualResolution.y < captureGridSize) then
if (mode ~= "live" and mode ~= "animation") and (Coords.VirtualResolution.x < captureGridSize or Coords.VirtualResolution.y < captureGridSize) then
Message:ShowGeneralSettingsProblem(
"The virtual resolution is smaller than the capture grid size.",
"This means that you will get black areas in your final stitched image.",

View File

@ -1,8 +1,9 @@
-- Copyright (c) 2022 David Vogel
-- Copyright (c) 2022-2024 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
local NXML = require("luanxml.nxml")
local Vec2 = require("noita-api.vec2")
-- List of components that will be disabled on every encountered entity.
@ -27,22 +28,65 @@ Config.ComponentsToDisable = {
--"AudioComponent",
}
local CHUNK_SIZE = 512
---Returns the rectangle of the base area as two vectors.
---@return Vec2 TopLeft Top left corner in world coordinates.
---@return Vec2 BottomRight Bottom right corner in world coordinates. This pixel is not included in the final rectangle.
local function getBaseArea()
local xml = NXML.parse(ModTextFileGetContent("data/biome/_biomes_all.xml"))
local width, height = BiomeMapGetSize()
local offsetX, offsetY = math.floor(width/2), xml.attr.biome_offset_y -- TODO: This may not be right. Check what Noita is really doing when we have a biome map with an odd width.
return Vec2(-offsetX, -offsetY)*CHUNK_SIZE, Vec2(-offsetX+width, -offsetY+height)*CHUNK_SIZE
--return Vec2(-17920, -7168), Vec2(17920, 17408) -- Coordinates for a "New Game" without mods or anything.
end
---A list of capture areas.
---This contains functions that determine the capture area based on the biome size and other parameters.
---The returned vectors are the top left corner, and the bottom right corner of the capture area in world coordinates.
---The bottom right corner pixel is not included in the rectangle.
---@type table<string, fun():Vec2, Vec2>
Config.CaptureArea = {
-- Base layout: Every part outside this is based on a similar layout, but uses different materials/seeds.
["1x1"] = {
TopLeft = Vec2(-17920, -7168), -- in world coordinates.
BottomRight = Vec2(17920, 17408), -- in world coordinates. This pixel is not included in the rectangle.
},
["1x1"] = getBaseArea,
-- Main world: The main world with 3 parts: sky, normal and hell.
["1x3"] = {
TopLeft = Vec2(-17920, -31744), -- in world coordinates.
BottomRight = Vec2(17920, 41984), -- in world coordinates. This pixel is not included in the rectangle.
},
["1x3"] = function()
local width, height = BiomeMapGetSize()
local topLeft, bottomRight = getBaseArea()
return topLeft + Vec2(0, -height)*CHUNK_SIZE, bottomRight + Vec2(0, height)*CHUNK_SIZE
--return Vec2(-17920, -31744), Vec2(17920, 41984) -- Coordinates for a "New Game" without mods or anything.
end,
-- -1 parallel world: The parallel world with 3 parts: sky, normal and hell.
["1x3 -1"] = function()
local width, height = BiomeMapGetSize()
local topLeft, bottomRight = getBaseArea()
return topLeft + Vec2(-width, -height)*CHUNK_SIZE, bottomRight + Vec2(-width, height)*CHUNK_SIZE
--return Vec2(-17920, -31744) + Vec2(-35840, 0), Vec2(17920, 41984) + Vec2(-35840, 0) -- Coordinates for a "New Game" without mods or anything.
end,
-- +1 parallel world: The parallel world with 3 parts: sky, normal and hell.
["1x3 +1"] = function()
local width, height = BiomeMapGetSize()
local topLeft, bottomRight = getBaseArea()
return topLeft + Vec2(width, -height)*CHUNK_SIZE, bottomRight + Vec2(width, height)*CHUNK_SIZE
--return Vec2(-17920, -31744) + Vec2(35840, 0), Vec2(17920, 41984) + Vec2(35840, 0) -- Coordinates for a "New Game" without mods or anything.
end,
-- Extended: Main world + a fraction of the parallel worlds to the left and right.
["1.5x3"] = {
TopLeft = Vec2(-25600, -31744), -- in world coordinates.
BottomRight = Vec2(25600, 41984), -- in world coordinates. This pixel is not included in the rectangle.
},
["1.5x3"] = function()
local width, height = BiomeMapGetSize()
local topLeft, bottomRight = getBaseArea()
return topLeft + Vec2(-math.floor(0.25*width), -height)*CHUNK_SIZE, bottomRight + Vec2(math.floor(0.25*width), height)*CHUNK_SIZE
--return Vec2(-25600, -31744), Vec2(25600, 41984) -- Coordinates for a "New Game" without mods or anything. These coordinates may not exactly be 1.5 of the base width for historic reasons.
end,
-- Extended: Main world + each parallel world to the left and right.
["3x3"] = function()
local width, height = BiomeMapGetSize()
local topLeft, bottomRight = getBaseArea()
return topLeft + Vec2(-width, -height)*CHUNK_SIZE, bottomRight + Vec2(width, height)*CHUNK_SIZE
--return Vec2(-53760, -31744), Vec2(53760, 41984) -- Coordinates for a "New Game" without mods or anything.
end,
}

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2019-2022 David Vogel
-- Copyright (c) 2019-2023 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
@ -24,6 +24,13 @@ ffi.cdef([[
LONG bottom;
} RECT;
typedef struct {
LONG x;
LONG y;
LONG width;
LONG height;
} GLViewportDims;
bool GetRect(RECT* rect);
bool Capture(RECT* rect, int x, int y, int sx, int sy);
]])

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2019-2022 David Vogel
-- Copyright (c) 2019-2024 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
@ -12,6 +12,7 @@
--------------------------
local Coords = require("coordinates")
local DebugAPI = require("noita-api.debug")
----------
-- Code --
@ -127,12 +128,13 @@ function Message:ShowWrongResolution(callback, desc)
"The resolution changed:",
desc or "",
" ",
"To fix:",
"- Deselect and select the Noita window, or",
"- restart Noita or revert the resolution change."
"Press the button at the bottom to set up and close Noita automatically.",
" ",
"You can always reset any custom settings by right clicking the `start capture`",
"button at the top left.",
},
Actions = {
{ Name = "Query settings again", Hint = nil, HintDesc = nil, Callback = function() Coords:ReadResolutions() end },
{ Name = "Setup and close (May corrupt current save!)", Hint = nil, HintDesc = nil, Callback = callback },
},
AutoClose = true, -- This message will automatically close.
}
@ -186,13 +188,53 @@ end
function Message:ShowModificationUnsupported(realm, name, value)
self.List = self.List or {}
self.List["ModificationFailed"] = {
self.List["ModificationFailed"] = self.List["ModificationFailed"] or {
Type = "warning",
Lines = {
string.format("Couldn't modify %q in %q realm.", name, realm),
" ",
"This simply means that this modification is not supported for the Noita version you are using.",
"Feel free to open an issue at https://github.com/Dadido3/noita-mapcap.",
},
}
-- Create or append to list of modifications.
-- We have to prevent duplicate entries.
self.List["ModificationFailed"].ModificationEntries = self.List["ModificationFailed"].ModificationEntries or {}
local found
for _, modEntry in ipairs(self.List["ModificationFailed"].ModificationEntries) do
if modEntry.realm == realm and modEntry.name == name then
found = true
break
end
end
if not found then
table.insert(self.List["ModificationFailed"].ModificationEntries, {realm = realm, name = name, value = value})
end
-- Build message lines.
self.List["ModificationFailed"].Lines = {"The mod couldn't apply the following changes:"}
table.insert(self.List["ModificationFailed"].Lines, " ")
for _, modEntry in ipairs(self.List["ModificationFailed"].ModificationEntries) do
table.insert(self.List["ModificationFailed"].Lines, string.format("- %q in %q realm", modEntry.name, modEntry.realm))
end
table.insert(self.List["ModificationFailed"].Lines, " ")
table.insert(self.List["ModificationFailed"].Lines, "This simply means that the mod can't automatically apply this change in the Noita version you are using.")
table.insert(self.List["ModificationFailed"].Lines, "If you are running a non-beta version of Noita, feel free to open an issue at https://github.com/Dadido3/noita-mapcap.")
-- Tell the user to change some settings manually, if possible.
local manuallyWithF7 = {}
local possibleManualWithF7 = {mPostFxDisabled = true, mGuiDisabled = true, mGuiHalfSize = true, mFogOfWarOpenEverywhere = true, mTrailerMode = true, mDayTimeRotationPause = true, mPlayerNeverDies = true, mFreezeAI = true}
for _, modEntry in ipairs(self.List["ModificationFailed"].ModificationEntries) do
if modEntry.realm == "processMemory" and possibleManualWithF7[modEntry.name] then
table.insert(manuallyWithF7, modEntry)
end
end
if #manuallyWithF7 > 0 then
table.insert(self.List["ModificationFailed"].Lines, " ")
table.insert(self.List["ModificationFailed"].Lines, "You can apply the setting manually:")
table.insert(self.List["ModificationFailed"].Lines, " ")
table.insert(self.List["ModificationFailed"].Lines, "- Press F7 to open the debug menu.")
for _, modEntry in ipairs(manuallyWithF7) do
table.insert(self.List["ModificationFailed"].Lines, string.format("- Change %q to %q.", modEntry.name, modEntry.value))
end
table.insert(self.List["ModificationFailed"].Lines, "- Press F7 again to close the menu.")
table.insert(self.List["ModificationFailed"].Lines, "- Close this warning when you are done.")
end
end

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2022-2023 David Vogel
-- Copyright (c) 2022-2024 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
@ -188,6 +188,86 @@ function Modification.SetMemoryOptions(memory)
mPlayerNeverDies = function(value) ffi.cast("char*", 0x0111A5C2)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x0111A5C3)[0] = value end,
},
{_Offset = 0x00F8BA04, _BuildString = "Build Jan 18 2024 12:57:44", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010F91FC+0)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x010F91FC+1)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010F91FC+2)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010F91FC+3)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010F91FC+4)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010F91FC+5)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010F91FC+6)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010F91FC+7)[0] = value end,
},
{_Offset = 0x0117091C, _BuildString = "Build Feb 2 2024 14:29:06", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x0130585C)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x0130585D)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x0130585E)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0130585F)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x01305860)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x01305861)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x01305862)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x01305863)[0] = value end,
},
{_Offset = 0x01173F34, _BuildString = "Build Feb 6 2024 15:54:02", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x0130982C+0)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x0130982C+1)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x0130982C+2)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0130982C+3)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x0130982C+4)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x0130982C+5)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x0130982C+6)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x0130982C+7)[0] = value end,
},
{_Offset = 0x01182F70, _BuildString = "Build Mar 25 2024 17:42:49", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x013198AC+0)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x013198AC+1)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x013198AC+2)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x013198AC+3)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x013198AC+4)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x013198AC+5)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x013198AC+6)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x013198AC+7)[0] = value end,
},
{_Offset = 0x011871FC, _BuildString = "Build Apr 6 2024 20:50:04", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x0131D89C+0)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x0131D89C+1)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x0131D89C+2)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0131D89C+3)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x0131D89C+4)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x0131D89C+5)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x0131D89C+6)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x0131D89C+7)[0] = value end,
},
{_Offset = 0x0118718C, _BuildString = "Build Apr 8 2024 18:07:16", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x0131D8DC+0)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x0131D8DC+1)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x0131D8DC+2)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0131D8DC+3)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x0131D8DC+4)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x0131D8DC+5)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x0131D8DC+6)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x0131D8DC+7)[0] = value end,
},
{_Offset = 0x0118FD3C, _BuildString = "Build Aug 12 2024 21:10:13", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x01327D3C+0)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x01327D3C+1)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x01327D3C+2)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x01327D3C+3)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x01327D3C+4)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x01327D3C+5)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x01327D3C+6)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x01327D3C+7)[0] = value end,
},
{_Offset = 0x0118FD3C, _BuildString = "Build Aug 12 2024 21:43:22", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x01327D3C+0)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x01327D3C+1)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x01327D3C+2)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x01327D3C+3)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x01327D3C+4)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x01327D3C+5)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x01327D3C+6)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x01327D3C+7)[0] = value end,
},
},
},
[false] = {
@ -248,13 +328,90 @@ function Modification.SetMemoryOptions(memory)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x00E24E6C, _BuildString = "Build Jan 18 2024 13:01:21", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x00642A47+6)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x00FECE94, _BuildString = "Build Feb 2 2024 14:33:26", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006AD407)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x00FEEFC0, _BuildString = "Build Feb 6 2024 15:58:22", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006AD611+6)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x00FF21D8, _BuildString = "Build Feb 9 2024 15:52:49", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006AE101+6)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x00FF22D4, _BuildString = "Build Feb 12 2024 19:07:19", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006AE161+6)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x00E23E80, _BuildString = "Build Feb 14 2024 07:46:57", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006427A7+6)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x00FFDB54, _BuildString = "Build Mar 25 2024 17:48:04", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006B1FD8+6)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x01001DDC, _BuildString = "Build Apr 6 2024 20:54:23", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006B35B5+6)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x01001DF4, _BuildString = "Build Apr 8 2024 18:11:27", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006B3355+6)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x01007CA4, _BuildString = "Build Aug 12 2024 21:14:23", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006B3925+6)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
{_Offset = 0x01007CA4, _BuildString = "Build Aug 12 2024 21:48:01", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006B3925+6)
Memory.VirtualProtect(ptr, 1, Memory.PAGE_EXECUTE_READWRITE)
ptr[0] = value -- This basically just changes the value that Noita forces to the "mods_have_been_active_during_this_run" member of the WorldStateComponent when any mod is enabled.
end,
},
},
},
}
-- Look up the tree and set options accordingly.
local level1 = lookup[DebugGetIsDevBuild()]
local level1 = lookup[DebugAPI.IsDevBuild()]
level1 = level1 or {}
local level2 = level1[ffi.os]
@ -291,6 +448,13 @@ function Modification.PatchFiles(patches)
end
ModTextFileSetContent("data/shaders/post_final.frag", postFinal)
end
if patches.PostFinalReplace then
local postFinal = ModTextFileGetContent("data/shaders/post_final.frag")
for k, v in pairs(patches.PostFinalReplace) do
postFinal = postFinal:gsub(k, v)
end
ModTextFileSetContent("data/shaders/post_final.frag", postFinal)
end
end
---Returns tables with user requested game configuration changes.
@ -312,12 +476,16 @@ function Modification.RequiredChanges()
config["internal_size_h"] = tostring(Vec2(ModSettingGet("noita-mapcap.internal-resolution")).y)
config["backbuffer_width"] = config["window_w"]
config["backbuffer_height"] = config["window_h"]
config["fullscreen"] = "0"
magic["VIRTUAL_RESOLUTION_X"] = tostring(Vec2(ModSettingGet("noita-mapcap.virtual-resolution")).x)
magic["VIRTUAL_RESOLUTION_Y"] = tostring(Vec2(ModSettingGet("noita-mapcap.virtual-resolution")).y)
-- Set virtual offset to prevent/reduce not correctly drawn pixels at the window border.
magic["GRID_RENDER_BORDER"] = "3" -- This will widen the right side of the virtual rectangle. It also shifts the world coordinates to the right.
magic["VIRTUAL_RESOLUTION_OFFSET_X"] = "-3"
magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "0"
magic["STREAMING_CHUNK_TARGET"] = "16" -- Keep more chunks alive.
magic["GRID_MAX_UPDATES_PER_FRAME"] = "1024" -- Allow more pixel physics simulation steps (in 32x32 regions) per frame. With too few, objects can glitch through the terrain/explode.
magic["GRID_MIN_UPDATES_PER_FRAME"] = "0" -- Also allow no updates.
else
-- Reset some values if there is no custom resolution requested.
config["internal_size_w"] = "1280"
@ -329,13 +497,12 @@ function Modification.RequiredChanges()
magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "-1"
end
-- Always expect a fullscreen mode of 0 (windowed).
-- Capturing will not work in fullscreen.
config["fullscreen"] = "0"
-- Also disable screen shake.
config["screenshake_intensity"] = "0"
-- And disable the cursor being rendered by Noita itself, which would make it appear in screen captures.
config["application_rendered_cursor"] = "0"
magic["DRAW_PARALLAX_BACKGROUND"] = ModSettingGet("noita-mapcap.disable-background") and "0" or "1"
-- These magic numbers seem only to work in the dev build.
@ -361,13 +528,24 @@ function Modification.RequiredChanges()
FOG_FOREGROUND_NIGHT = "vec4(0.0,0.0,0.0,1.0)",
FOG_BACKGROUND_NIGHT = "vec3(0.0,0.0,0.0)",
}
-- Disable color grading, which may make the world look a tad more blue when there is freezing/snowing weather.
-- This is dependent on the seed and the PC's wall clock, and it only snows in December, January or February.
patches.PostFinalReplace = {
["color%.rgb = mix%( color, additive_overlay_color%.rgb, additive_overlay_color%.a %);"] = "",
["color = mix%(color, vec3%(%(color%.r %+ color%.g %+ color%.b%) %* 0%.3333%), color_grading%.a%);"] = "",
["color = color %* color_grading%.rgb;"] = "// Here lies the remains of the tone-mapping/color grading code. 2019-2024. RIP",
}
end
if ModSettingGet("noita-mapcap.disable-shaders-gui-ai") and DebugAPI.IsDevBuild() then
memory["mPostFxDisabled"] = 1
memory["mGuiDisabled"] = 1
memory["mFreezeAI"] = 1
memory["mTrailerMode"] = 1 -- Is necessary for chunks to correctly load when DEBUG_PAUSE_GRID_UPDATE is enabled.
end
if DebugAPI.IsDevBuild() and magic["DEBUG_PAUSE_GRID_UPDATE"] == "1" then
memory["mTrailerMode"] = 1 -- This is necessary for chunks to correctly load when DEBUG_PAUSE_GRID_UPDATE is enabled.
end
if ModSettingGet("noita-mapcap.disable-mod-detection") and not DebugAPI.IsDevBuild() then

11
go.mod
View File

@ -1,9 +1,10 @@
module github.com/Dadido3/noita-mapcap
go 1.21
go 1.22
require (
github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1
github.com/Dadido3/go-libwebp v0.3.0
github.com/cheggaaa/pb/v3 v3.1.4
github.com/coreos/go-semver v0.3.1
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237
@ -34,9 +35,9 @@ require (
github.com/rivo/uniseg v0.4.4 // indirect
github.com/tdewolff/minify/v2 v2.20.10 // indirect
github.com/tdewolff/parse/v2 v2.7.7 // indirect
golang.org/x/image v0.14.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.16.0 // indirect
star-tex.org/x/tex v0.4.0 // indirect
)

18
go.sum
View File

@ -4,6 +4,8 @@ github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1 h1:LejjvYg4tCW5HO
github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1/go.mod h1:cnC/60IoLiDM0GhdKYJ6oO7AwpZe1IQfPnSKlAURgHw=
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v/9acCWA64Yz/HDLqjcRTvc0noQACi4MsJw=
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic=
github.com/Dadido3/go-libwebp v0.3.0 h1:Qr3Gt8Kn4qgemezDVnjAJffMB9C0QJhxP+9u0U5mC94=
github.com/Dadido3/go-libwebp v0.3.0/go.mod h1:rYiWwlI58XRSMUFMw23nMezErbjX3Z5Xv0Kk3w6Mwwo=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
@ -85,11 +87,11 @@ github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4A
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uyPfhPeDWD+n+JgppptE=
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -97,14 +99,14 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gonum.org/v1/plot v0.14.0 h1:+LBDVFYwFe4LHhdP8coW6296MBEY4nQ+Y4vuUpJopcE=
gonum.org/v1/plot v0.14.0/go.mod h1:MLdR9424SJed+5VqC6MsouEpig9pZX2VZ57H9ko2bXU=

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2022 David Vogel
-- Copyright (c) 2022-2024 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
@ -108,7 +108,6 @@ end
---Called *every* time the game is about to start updating the world.
function OnWorldPreUpdate()
Message:CatchException("OnWorldPreUpdate", function()
-- Coroutines aren't run every frame in this lua sandbox, do it manually here.
wake_up_waiting_threads(1)

View File

@ -62,7 +62,7 @@ func addPathToZip(zipWriter *zip.Writer, srcPath, archiveBasePath string, ignore
return err
}
header.Name = archivePath
header.Name = filepath.ToSlash(archivePath)
header.Method = zip.Deflate
writer, err := zipWriter.CreateHeader(header)

View File

@ -1,4 +1,4 @@
-- Copyright (c) 2022-2023 David Vogel
-- Copyright (c) 2022-2024 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
@ -67,9 +67,9 @@ modSettings = {
{
id = "capture-mode",
ui_name = "Mode",
ui_description = "How the mod captures:\n- Live: Capture as you play along.\n- Area: Capture a defined area of the world.\n- Spiral: Capture in a spiral around a starting point indefinitely.",
ui_description = "How the mod captures:\n- Live: Capture as you play along.\n- Area: Capture a defined area of the world.\n- Spiral: Capture in a spiral around a starting point indefinitely.\n- Animation: Capture the screen frame by frame.",
value_default = "live",
values = { { "live", "Live" }, { "area", "Area" }, { "spiral", "Spiral" } },
values = { { "live", "Live" }, { "area", "Area" }, { "spiral", "Spiral" }, { "animation", "Animation"} },
scope = MOD_SETTING_SCOPE_RUNTIME,
},
{
@ -95,7 +95,7 @@ modSettings = {
ui_name = " Rectangle",
ui_description = "The area to be captured.\nSee documentation for more information.",
value_default = "1x1",
values = { { "1x1", "Base layout" }, { "1x3", "Main World" }, { "1.5x3", "Extended" }, { "custom", "Custom" } },
values = { { "1x1", "Base layout" }, { "1x3", "Main World" }, { "1x3 -1", "-1 Parallel World" }, { "1x3 +1", "+1 Parallel World" }, { "1.5x3", "Extended" }, { "3x3", "3 Worlds" }, { "custom", "Custom" } },
scope = MOD_SETTING_SCOPE_RUNTIME,
show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" end,
},
@ -143,7 +143,7 @@ modSettings = {
value_default = "512",
allowed_characters = "0123456789",
scope = MOD_SETTING_SCOPE_RUNTIME,
show_fn = function() return modSettings:GetNextValue("capture-mode") ~= "live" end,
show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" or modSettings:GetNextValue("capture-mode") == "spiral" end,
},
{
id = "pixel-scale",
@ -157,6 +157,18 @@ modSettings = {
scope = MOD_SETTING_SCOPE_RUNTIME,
change_fn = roundChange,
},
{
id = "capture-delay",
ui_name = "Capture delay",
ui_description = "Additional delay before a screen capture is taken.\nThis can help the world to be populated, and the physics simulation to settle down.\nA setting of 0 means that the screenshot is taken as soon as possible.\n \nUse a value of 10 for a good result without slowing the capture process down too much.",
value_default = 0,
value_min = 0,
value_max = 60,
value_display_multiplier = 1,
value_display_formatting = " $0 frames",
scope = MOD_SETTING_SCOPE_RUNTIME,
show_fn = function() return modSettings:GetNextValue("capture-mode") == "area" or modSettings:GetNextValue("capture-mode") == "spiral" end,
},
{
id = "custom-resolution-live",
ui_name = "Use custom resolution",