Compare commits

...

256 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
d774cf373d Add parameters for DZI output 2023-12-31 17:44:07 +01:00
9da52a3f70 Add compatibility with new beta versions
- `Build Dec 30 2023 19:37:04`
- `Build Dec 30 2023 19:40:49`
2023-12-30 23:21:26 +01:00
e83aa6803a Add support for new Noita versions
- Add support for Build Dec 29 2023 23:36:18
- Add support for Build Dec 29 2023 23:40:18
2023-12-30 14:05:16 +01:00
f7426f3ed5 Don't modify GRID_MAX and MIN magic numbers 2023-12-30 13:58:39 +01:00
478e1284fb Add code that stops grid updates in regular build
This is not exposed to the end user yet, as it is buggy.
2023-12-30 13:34:13 +01:00
8bb8adf1ba Scan world line by line instead of using hilbert curve
This should prevent some glitches due to the way Noita generates the world.
2023-12-28 17:47:36 +01:00
355521b144 Change dziOverlap to 2
With 0, OpenSeadragon has problems drawing the images correctly.
And with 1, we introduce rounding errors when creating the smaller zoom levels.
2023-12-23 12:44:29 +01:00
b76124b2e4 Fix error when there already is an DZI directory 2023-12-23 10:41:05 +01:00
f0ee3e2399 Remove DZI overlap, as it seems unnecessary 2023-12-23 10:40:30 +01:00
b9fc890581 Update stitcher README.md and flag usage string
#7
2023-12-23 01:41:59 +01:00
a96431361f Prevent stdout spam when exporting DZI files 2023-12-23 01:23:53 +01:00
88507af167 Increase default CacheRowHeight 2023-12-23 01:17:40 +01:00
7a6915480b Add first sketch of Deep Zoom Image exporter 2023-12-23 01:17:20 +01:00
915da73845 Add SubStitchedImage 2023-12-23 01:16:25 +01:00
6d028d4064 Fix stitched image cache generation 2023-12-23 01:15:36 +01:00
a0d5c13557 Let StitchedImage invalidate cache based on time 2023-12-23 01:15:05 +01:00
3016919348 Add Bounds method to ImageTiles 2023-12-23 01:11:58 +01:00
cbdd925c30 Correct image.Rectangle downscaling 2023-12-23 01:09:50 +01:00
a0168df91f Change export functions to use image.Image 2023-12-23 01:09:23 +01:00
182373d3cc Refactor export functions
- Pass output path as parameter
- Return and handle errors correctly
2023-12-22 11:03:04 +01:00
0454e29e34 Add JPEG encoder 2023-12-22 10:13:22 +01:00
a70a5a4d1a Prepare stitcher for multiple output formats 2023-12-22 10:07:02 +01:00
f5a3bad396 Update dependencies
- Push minimal go version to 1.21
- tdewolff/canvas compatibility updates
2023-12-21 14:04:50 +01:00
905f629d2c Add support for newer Noita builds
- Add Build Dec 19 2023 18:34:31 support
- Add Build Dec 19 2023 18:38:23 support
- Add Build Dec 21 2023 00:07:29 support
- Add Build Dec 21 2023 00:11:06 support
2023-12-21 13:42:25 +01:00
David Vogel
b11b27d6c3
Merge pull request #23 from Dadido3/dependabot/go_modules/golang.org/x/image-0.10.0
Bump golang.org/x/image from 0.5.0 to 0.10.0
2023-11-02 22:37:41 +01:00
dependabot[bot]
6ef2f7d1d3
Bump golang.org/x/image from 0.5.0 to 0.10.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.5.0 to 0.10.0.
- [Commits](https://github.com/golang/image/compare/v0.5.0...v0.10.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-02 21:34:11 +00:00
8f729d3829 Add support for the Jul 26 2023 beta 2023-07-27 10:40:41 +02:00
a6a0cc14e1 Make compatible with Jun 19 2023 builds
Also change how the matching works, as offsets may not change between builds.

fixes #22
2023-06-21 18:43:57 +02:00
2b0f6a25f6 Remove travis build status 2023-04-17 11:21:49 +02:00
d69177cd3b Hide "Disable mod detection" in dev build 2023-04-17 11:19:16 +02:00
f992748443 Add compatibility with Mar 11 2023 build
closes #21
closes #20
2023-04-16 19:13:53 +02:00
22b5c1827d Merge branch 'master' of https://github.com/Dadido3/noita-mapcap 2023-03-15 23:58:57 +01:00
b22b42a8d1 Update message text
- Add possible fix to "resolution changed" message
2023-03-15 23:58:50 +01:00
f1a3010d72 Fix exception with memory modification 2023-03-15 23:57:17 +01:00
David Vogel
ad50faebc9
Merge pull request #19 from Dadido3/dependabot/go_modules/golang.org/x/image-0.5.0
Bump golang.org/x/image from 0.0.0-20220617043117-41969df76e82 to 0.5.0
2023-03-06 23:45:06 +01:00
dependabot[bot]
c72574c55d
Bump golang.org/x/image from 0.0.0-20220617043117-41969df76e82 to 0.5.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.0.0-20220617043117-41969df76e82 to 0.5.0.
- [Release notes](https://github.com/golang/image/releases)
- [Commits](https://github.com/golang/image/commits/v0.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-06 22:43:26 +00:00
9494588e7b Add example code for killing/removing creatures 2023-02-25 13:51:17 +01:00
7a85f646cb Merge branch 'master' of https://github.com/Dadido3/noita-mapcap 2023-02-25 13:16:12 +01:00
d8dab5c318 Update README.md 2023-02-25 13:16:09 +01:00
David Vogel
b1971bb4be
Merge pull request #18 from Dadido3/dependabot/go_modules/golang.org/x/text-0.3.8
Bump golang.org/x/text from 0.3.7 to 0.3.8
2023-02-23 11:31:18 +01:00
dependabot[bot]
1652b278cb
Bump golang.org/x/text from 0.3.7 to 0.3.8
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.3.7 to 0.3.8.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.3.7...v0.3.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-23 10:24:04 +00:00
486f8e642d Update README.md 2022-09-06 11:33:42 +02:00
f964f5d769 Remove debug print
Getting error when starting new game #16
2022-08-29 16:21:50 +02:00
28a768a130 Fix package.path separators
Getting error when starting new game #16
2022-08-29 16:21:01 +02:00
959b198e46 Fix typos 2022-08-27 14:32:01 +02:00
18682ed441 Fully implement "disable-mod-detection" setting
- Catch exceptions in OnPausedChanged
- Unhide "disable-mod-detection" setting
- Add error message for unsupported modifications
- Change memory modification lookup to contain functions
- Don't (re)enable mod detection automatically
- Add memory.lua library that allows to change the protection of memory regions.
2022-08-27 14:07:37 +02:00
28c07dfd25 Merge branch 'master' of https://github.com/Dadido3/noita-mapcap 2022-08-13 13:58:52 +02:00
fcfd8c88ff Add Voronoi blend method 2022-08-13 13:58:50 +02:00
David Vogel
6761492ea8
Create FUNDING.yml 2022-08-12 23:25:35 +02:00
615faac8e4 Ensure the github action uses at least go 1.19 2022-08-12 14:03:08 +02:00
72f8e92412 Prepare for new release
- Add new title image
- Rename tile-limit parameter to blend-tile-limit
- Update README.md
2022-08-12 13:14:18 +02:00
b2ed6f65d5 Use butt cap for player path drawing 2022-08-12 11:40:20 +02:00
c9d2a37903 Fix all possible data races
- Update to go 1.19 for new atomic types
- Use atomic for StitchedImage query counter
- Make sure that we don't copy any tile objects
2022-08-12 11:39:55 +02:00
65f7cb4e60 Improve stitching speed and resource usage
- Use QuickSelect algorithm for median filtering
- Use lists of uint8 instead of int for median filtering
- Fix GridifyRectangle
- Remove HilbertifyRectangle
- Add profiling.go
- Remove Profile.bat
- Add median blend tile-limit flag
- Print stitch duration
- Reduce StitchedImage cache image height
- Reduce StitchedImageCacheGridSize
- Improve StitchedImage caching
- Improve ImageTile caching
- Separate entity and entities
- Update stitcher README.md
- Add comments
2022-08-12 01:06:22 +02:00
c3f841a4ff Remove cleanup mode from stitcher 2022-08-11 11:59:53 +02:00
f5693b96f1 Remove old blend functions 2022-08-11 11:48:17 +02:00
df6c27924b Change from blend func to interface
- Combine all previous blend modes into one blend method
- Optimize BlendMethodMedian
2022-08-11 11:47:18 +02:00
3a73e13fb7 Refactor and improve stitcher
- Replace MedianBlendedImage with StitchedImage, a general implementation of a stitcher
- Don't use hilbert curve when regenerating cache image
- Cut workload rectangles to be always inside the cache image boundaries
- Rename stitch.go to main.go
- Add interface for overlays
- Change how overlays are handled and drawn
- Reduce error returns to simplify a lot of code
- Add several blend functions
- Remove offset field from image tile
2022-08-11 11:10:07 +02:00
7a4dbeddf1 Fix output pixel scale option
- When set to 0, correctly use the actual pixel scaling factor
- Scale player path by pixel scale factor
2022-08-11 10:56:24 +02:00
7d250d6405 Remove pre-render mode from stitcher & Cleanup 2022-08-10 21:04:17 +02:00
cd1428706e Update stitcher README.md 2022-08-10 20:47:59 +02:00
1e5249d436 Add player path tracking and drawing in live mode 2022-08-10 20:41:57 +02:00
0044075cbf Draw entities and their component's bounding boxes 2022-08-08 23:05:58 +02:00
9406b598f8 Change JSON number formatting to be more conform 2022-08-08 22:07:00 +02:00
6f2be8486e Fix image caching 2022-08-08 12:36:55 +02:00
baea6292f1 Test to disable mod detection 2022-08-08 02:54:48 +02:00
85144f4b8f Change free camera speed 2022-08-08 02:52:49 +02:00
99fd8ce94f Add more API functions to wrapper 2022-08-07 22:31:09 +02:00
23ca6ac8c3 Fix release not including submodules
- Github checkout action doesn't load submodules by default, enable that
- Update README.md
2022-07-31 20:59:16 +02:00
5884b49518 Prepare for 2.0 release
- Update README.md
- Add "Open stitch directory" button
- Add message box example
- Add example settings
2022-07-30 19:10:48 +02:00
1b767f9465 Update promptui to fix console input on windows 2022-07-30 12:23:40 +02:00
403167b366 Fix compatibility stuff
- Make dofile more conform to standard lua
- Move dofile from live-reload.lua to compatibility.lua
- Let dofile and require throw errors on script errors
- Fix bug in recursion detection
- Remove Noita's dofile annotation
- Fix some EmmyLua annotations
- Improve print replacement
2022-07-30 00:32:11 +02:00
e863ba459b Split UI message text to fit on screen 2022-07-29 22:49:12 +02:00
eb552537c2 Improve capturing
- Center viewport to grid cells, not chunks
- Check window size when taking screenshot
2022-07-29 22:48:42 +02:00
3fa95de8e6 Add capture grid size check
- Check if capture grid size is smaller than the virtual resolution
- Automatically close resolved messages
2022-07-29 20:44:36 +02:00
a2cb806ffa Add progress bar
- Change process runner to return state table
- Add progress bar graphics
- Add progress bar UI
- Fill progress runner context state with more capturing info
2022-07-29 19:42:44 +02:00
014cba54af Add mod setting to hide UI 2022-07-29 17:37:58 +02:00
62142101fc Make fog black 2022-07-29 16:57:45 +02:00
7ea4f058c8 Add mod setting for custom/fixed seed 2022-07-29 16:57:28 +02:00
6ab8903c9b Reset virtual offset when no custom resolution is set 2022-07-29 16:34:25 +02:00
9af974cb10 Add more game modifications
- Add more process memory based modifications
- Change virtual offset and border
- Set trailer mode with disable-shaders-gui-ai mod setting
2022-07-29 16:09:38 +02:00
b19c70c9d0 Disable screenshake by default 2022-07-29 15:29:15 +02:00
2f8a8b2718 Update coordinate transformation
- Get rid of hardcoded pixel perfect virtual offset
- Add virtual border parameter
- Update tests
2022-07-29 13:49:15 +02:00
bb6fb51ef9 Fix perk.lua override 2022-07-29 11:51:20 +02:00
b4a0b26dfd Add option to disable most post FX
- Add file patching logic
2022-07-29 11:29:14 +02:00
58803cad1d Add memory manipulation to set specific options
#14
2022-07-29 00:26:57 +02:00
83be64dd74 Fix SetCameraFree bugging up in OnWorldInitialized 2022-07-29 00:25:19 +02:00
b81fcd8417 Decrease timeout until wiggle 2022-07-28 23:05:25 +02:00
63dd11fd2d Add more checks & Other fixes
- Rename Check:Resolutions to Check:Regular
- Add check for virtual offset
- Add check if specific mod settings are changed
- Revert scope of resolution mod settings to "runtime"
- Add restart request message
- Set virtual and internal resolutions to default if no custom resolution is defined
2022-07-28 22:34:56 +02:00
98f663f200 Add more game modification options 2022-07-28 19:42:43 +02:00
cc7aa35627 Add capture mode to start capture button tooltip 2022-07-28 18:54:44 +02:00
fd7fb31338 Change when CameraBound component is removed
Remove CameraBound component when capturing entities, but not when modifying entities
2022-07-28 17:00:24 +02:00
25a28c8469 Change how DoesWorldExistAt is called
Use Noita's bounds, in the hope that Noita uses the same coordinates for chunk loading.
2022-07-28 14:18:01 +02:00
321208ba8a Query live capture settings while capturing
- Change default capture interval to 30 frames
- Add todo comments
2022-07-28 14:01:44 +02:00
c4e59156c8 Make more clear that force closing noita can corrupt saves 2022-07-28 13:29:10 +02:00
f79d48fdc0 Set camera free based on mod settings 2022-07-28 13:27:02 +02:00
2cd9f1fc76 Always modify Noita's virtual offset 2022-07-28 12:49:03 +02:00
6becf72420 Start entity capturing based on mod settings
- Rename disable-entity-components mod setting to modify-entities
- Add IsPlayer and IsInvisible methods to entity API
- Don't modify the player entity
2022-07-28 12:38:26 +02:00
640a241d38 Implement more capturing modes & Fix spiral 2022-07-28 12:06:47 +02:00
fac941a156 Add some default values to settings descriptions 2022-07-28 11:58:11 +02:00
84dba8a9fa Fix ctx.progressCurrent not being set 2022-07-28 11:57:47 +02:00
78b2812593 Display stacktrace with Message:CatchException 2022-07-28 11:56:14 +02:00
6a016ed0b9 Handle newline characters in UI messages 2022-07-28 11:55:34 +02:00
5d7f258973 Show any live reload errors in the user interface 2022-07-28 10:32:23 +02:00
3208eed610 Allow negative numbers for vectors in settings 2022-07-28 10:21:39 +02:00
1eb6c10286 Move SuspendDrawing method up 2022-07-28 10:07:47 +02:00
38f83d19c9 Update description of EnableTrailerMode 2022-07-28 10:07:08 +02:00
22d385df32 Update distribution script 2022-07-28 10:06:05 +02:00
31fc11ef1b Improve user experience
- Modernise UI, and simplify its logic
- Add UI graphics
- Add modification.lua which contains everything to modify Noita settings
- Add message.lua which handles messages for users
- Add check.lua which checks things, triggers messages and suggest user actions
- Remove ACTIONS category from settings
- Add more live capturing parameters to settings
- Restrict vector input fields in settings
- Rename pixel-size setting to pixel-scale
- Let GetRect return two vectors instead of RECT object
- Add VirtualOffsetPixelPerfect and FullscreenMode field to Coords
- Fix captureScreenshot when the outputPixelScale is 0
- Show runtime errors in UI via message.lua
- Other small fixes
2022-07-28 01:48:49 +02:00
f0217ba856 Disable live reload 2022-07-27 18:21:49 +02:00
a4314f3e91 Fix missing default interval in LiveReload 2022-07-27 18:19:18 +02:00
6ca93b54d7 Add mod live reload ability
Allow mod to reload script files while Noita is running.
2022-07-27 18:17:26 +02:00
a2f5efc9e6 Rewrite capturing process
- Add process runner library that handles any processes
- Add global namespaces for main files
- Add config.lua and move capture area definitions into there
- Remove CAPTURE_PIXEL_SIZE and CAPTURE_GRID_SIZE variables
- Rename topLeftWorld in screen-capture.lua to topLeftOutput
- Rewrite all capturing processes and let them use the process runner
- Put UI redrawing outside of coroutine
- Clean up not needed stuff and get rid of most global variables
- Change how the UI is suspended when taking screenshots
- Start rewriting UI stuff
- Reformat ui.lua
- Fix comments
2022-07-27 00:06:09 +02:00
635085f923 Fix missing WrapID to Wrap rename 2022-07-26 22:32:47 +02:00
96c2da8f78 Refactor and clean up stuff
- Rename WrapID method to Wrap
- Fix pack function in component.lua
- Change default pixelscale to 1
- Add and fix documentation
2022-07-26 16:33:29 +02:00
bde0b2bbd8 Improve settings menu
- Simplify hiding logic
- Add callback that rounds values
- Improve settings hierarchy
- Fix modSettings:Get
2022-07-26 16:26:04 +02:00
4ee6c80bc6 Add first sketch of the settings menu 2022-07-26 12:58:10 +02:00
175d5ba969 Add virtual offset to coordinate transformation
- Add some general information on Noitas viewport transformation
- Update viewport transformation tests
2022-07-24 22:45:13 +02:00
0126e706cb Update capturing stuff
- Add ability to capture while normally playing
- Calculate capture area based on coordinate transformations
- Improve and simplify captureScreenshot function
- Move dynamic library wrappers into libraries folder
- Update capture.dll to support cropping and resizing
- Recompile capture.dll with newer PureBasic compiler that uses C backend
- Increase capture.dll worker threads to 6
- Increase capture.dll queue size by one
- Add Round and Rounded methods to Vec2
- Split magic number XML files for easier debugging
- Fix some EmmyLua annotations
- And and fix some comments
2022-07-24 22:05:34 +02:00
f2e582622e Add LuaNXML library
- Change VSCode lua addon to not ignore submodules
- Add function to read resolutions from Noita config file
2022-07-23 22:36:14 +02:00
afaedf9159 Add cyclic dependency detection 2022-07-23 21:15:34 +02:00
931c4df18a Refactoring, fixes and cleanup
- Move utils into Noita API wrapper
- Always overwrite require, with a fallback to the original
- Add library directory of mod to package.path, instead of the files directory
- Add Noita data/scripts/lib to package.path
- Fix dofile error handling
- Fix require not working right when module returns false
- Add init.lua to Noita API wrapper, that contains a table of all modules
- Fix Utils.GetSpecialDirectory
- Update README.md
2022-07-23 20:43:04 +02:00
0222350a7f Remove not needed class annotations 2022-07-23 17:57:44 +02:00
98370f6737 Fix some more stuff
- Rename CameraAPI.Pos to CameraAPI.GetPos
- Change some EmmyLua annotations that are supposedly wrong
- Remove debug printing
- Other small fixes
2022-07-23 17:45:06 +02:00
98dfb5fbb0 Cleanup, Refactoring and Fixes
- Remove unused util functions
- Put util stuff into its own namespace
- Move all initialization stuff into root init.lua
- Move progressBarString function into ui.lua
- Fix ffi.load error message
- Fix DebugAPI.BiomeMapGetFilename
- Move JSON lib into Noita API wrapper
- Move Vec2 lib into Noita API wrapper
- Move compatibility stuff into Noita API wrapper
- Emulate package tables if in restricted API mode
- Emulate require if in restricted API mode
- Use require instead of dofile_once everywhere
- Fix WrapID method to accept nil
- Add EmmyLua annotations for the default Noita API
- Add README.md to Noita API wrapper
2022-07-23 17:36:21 +02:00
994c44f1ba Move table.pack replacement into noita-api 2022-07-23 11:39:40 +02:00
2618558942 Remove left over dofile call 2022-07-23 10:59:24 +02:00
508771c347 Clean up 2022-07-23 10:57:25 +02:00
2acc4e7e93 Move hilbert curve lib into library directory 2022-07-23 10:55:44 +02:00
8841a57185 Remove unused entity 2022-07-23 10:12:23 +02:00
3d25084536 Add debug functions to Noita API wrapper 2022-07-23 01:13:51 +02:00
aa99e101b4 Split Noita API wrapper into several files 2022-07-22 21:31:40 +02:00
a30c3b0cbe Add camera functions to Noita API wrapper 2022-07-22 19:10:15 +02:00
bc504e7399 Change Lua formatting rules 2022-07-22 19:09:20 +02:00
43e265dc92 Add pixel perfect coordinates transformations 2022-07-22 19:08:59 +02:00
f7813c0da6 Add 2D vector library 2022-07-22 19:06:18 +02:00
4b869c0944 Move libraries into their own folder 2022-07-21 01:16:23 +02:00
0ec2776705 Change coroutines to OnWorldPreUpdate callback 2022-07-19 18:28:30 +02:00
6cf06d42d9 Remove CameraBoundComponent from entites 2022-07-19 18:27:31 +02:00
9f4aa9b038 Fix and update LoadToEntity API wrapper 2022-07-19 18:26:34 +02:00
926aa5bca8 Remove debug message 2022-07-19 13:55:08 +02:00
77bf19acf3 Improve entity capturing
- Set velocity of VelocityComponent to zero
- Prevent some random explosions
- Make entity capture independent of screen capture
2022-07-19 13:50:30 +02:00
98f9c23064 Remove artifical slowdown in capture.lua 2022-07-18 22:54:57 +02:00
8f3ecefa8b Reduce more animations for a cleaner image
- Add override misc\custom_cards\energy_shield.xml
- Add override misc\custom_cards\action.xml
- Add override entities\base_custom_card.xml
- Add override to data/scripts/perks/perk.lua that disables SpriteOffsetAnimatorComponent
- Add WrapID functions to Noita API
- Remove unecessary entity modifications in capture.lua
2022-07-18 22:53:34 +02:00
f58b005155 Several fixes and improvements
- Change vscode Lua runtime to LuaJIT
- Remove worm entity overrides
- Remove preparePlayer() that sets HP and other stuff
- Change how entites are captured
- Increase entity capture radius
- Capture entities as soon as possible
- Modify entites immediately after they are captured
- Make stringArgs in print local
- Fix JSON marshaling of Noita vectors
- Fix NoitaEntity:GetComponents() and NoitaEntity:GetFirstComponent()
- Add varArg support to NoitaComponent:SetValue() and NoitaComponent:ObjectSetValue()
- Update some EmmyLua annotations
- Fix custom GamePrint
- Add table.pack function that is missing in LuaJIT
2022-07-18 22:07:53 +02:00
40f31011e8 Get component values with correct type
#9
2022-07-18 12:55:51 +02:00
e8c6c8bb8f Rename entities file suffix to json 2022-07-18 11:48:49 +02:00
cfe4193974 Move Noita specific stuff out of JSON library
- Add JSON marshal "interface" that objects can implement
- Fix EmmyLua annotation
- Move JSON marshaler into noita-api.lua

#9
2022-07-18 11:47:59 +02:00
833ab41eeb Serialize entities to JSON
- Add JSON library that can marshal Noita entities and components
- Add Noita API wrapper that exposes entities and components as objects
- Change how the entities file is written, to support lightweight and crash proof appending of JSON data

#9
2022-07-18 01:32:44 +02:00
861272187a Change from entityID to tag for deduplication
The entityID is not unique per entity. Entites may get a new ID on reload. Or the same ID will be reused by another entity.

Attach "MapCaptured" tag to visited entities.
2022-07-17 17:26:08 +02:00
303f1a9c90 Capture entities and write into CSV file
Related issue: #9
2022-07-17 16:54:59 +02:00
79608d0518 Change build architecture to amd64
The precompiled capture DLL will still be x86
2022-07-17 14:40:32 +02:00
4551948460 Fix coordinate systems & Prepare for entity output
- More clearly describe which coordinate system is used
- Fix screen coordinates for CAPTURE_PIXEL_SIZE != 1
- Prepare entity output file
- Fix format of comments
2022-07-17 14:39:18 +02:00
48a152a219 Fix github actions (Try 2) 2022-07-16 23:28:45 +02:00
af890f4df1 Redo how the distribution artifact is created
- Add version information to the stitching tool
2022-07-16 23:22:18 +02:00
729cc50539 Remove distribution script 2022-07-16 17:36:14 +02:00
f238aeb66f Fix github actions (Try 1) 2022-07-16 17:34:01 +02:00
405ea876a4 Update tooling & Fix small things
- Fix some linter warnings
- Update copyright year
- Switch from travis to github actions
- Update libraries
- Switch to go 1.18
2022-07-16 17:29:26 +02:00
30d5bb3aae Merge branch 'master' of https://github.com/Dadido3/noita-mapcap 2022-07-16 17:00:36 +02:00
f27a954bd1 Don't panic on invalid image data
- If there is any image data error, just output an error message
- Also print out the filepath, so the user can delete the invalid image

fixes #11
2022-07-16 16:59:32 +02:00
David Vogel
cc0c501d7c
Update README.md 2022-05-24 17:06:50 +02:00
David Vogel
f1dd89ad86
Update README.md 2022-05-24 17:01:15 +02:00
51dabd544a Update README.md
#7
2020-11-08 18:43:34 +01:00
6a9c32cd99 Add different capture areas
- "Full" map capture is now "extended" map capture
- Made coordinates align with in-game chunks
- Add images for different capture modes
- Update README.md
- Add AREAS.md
2020-10-20 15:29:28 +02:00
0aa2a0f724 Fix missing compatibility.xml in artifacts 2020-10-18 21:29:09 +02:00
47f58553cf Fix some stuff
- Mark mod as compatible
- Change mod description
- Update go version
- Speed up image stitching 2x
- Fix stitching progress bar
2020-10-18 21:18:43 +02:00
c78a8b56ae Update capture method
- Capture hWnd HDC directly (Window can be behind other windows)
- Update README.md
2020-10-18 00:39:58 +02:00
478a6262e8 Fix chunk/pixel alignment
- Adjust coordinates, so that chunks are always in the center of the window
- Modify VIRTUAL_RESOLUTION_OFFSET_X/Y to match window output with expected coordinates
- Fix black line at the bottom of window
- Remove tile bleed/inset while stitching, as it is not needed anymore
2020-10-17 17:27:26 +02:00
151d1d88bd Use DoesWorldExistAt() to speed up capturing
- Remove any delays
- Move UI rendering into capture coroutine, so wait(...) can be reduced
- Use wait(0) instead of wait(1) in coroutines
- Use default value of STREAMING_CHUNK_TARGET
- Don't suggest to change framerate to 600 Hz
- Don't suggest to disable mTrailerMode
- Add "Done!" screen
- Update README.md

fixes #1
fixes #5
2020-06-01 22:40:47 +02:00
8cf480dc37 Optimized parameters
- Reduce CAPTURE_DELAY
- Increase STREAMING_CHUNK_TARGET
2020-05-31 18:00:24 +02:00
24fa53be27 Add countdown to UI 2020-05-31 17:59:36 +02:00
b8fb1d67cf Remove compatibility.xml
As it does not remove the "This mod has not been tested..." message
2020-05-30 20:01:55 +02:00
66de429ab8 Update to go 1.14 & Upgrade dependencies 2020-05-30 19:42:31 +02:00
c56e48b632 Mark mod as compatible with newer noita version 2020-05-30 19:30:08 +02:00
4252e8fbe6 Update README.md 2020-05-30 19:29:39 +02:00
0bc2c9122e Add profiling batch file 2020-05-30 19:26:53 +02:00
a6ff7f4bf9 Update copyright year 2020-05-30 18:16:50 +02:00
13373028cc Fix output image parameter 2019-12-21 22:25:47 +01:00
95c53b96a9 Disable worm movement 2019-12-01 12:47:51 +01:00
1d832ebad1 Add cleanup mode to stitcher 2019-11-30 18:28:17 +01:00
6703074900 Make compatible with nightmare mode
- Remove perks as they are not needed, and not all are available in nightmare mode
2019-11-28 21:05:50 +01:00
52e23df1ee Several stitcher updates
- Use different progress bar
- Add bounds parameter to MedianBlendedImage
- Add progress to MedianBlendedImage
- Replace the `-lowram` flag with `-prerender`
- Update README.md
2019-11-05 02:31:19 +01:00
d76dc20936 Add low RAM mode 2019-11-04 22:44:35 +01:00
dc42ee1eb5 Remove incomplete alignment algorithm 2019-11-04 20:52:47 +01:00
e1fbda1053 Update README.md 2019-11-04 17:56:09 +01:00
6065c26266 Prevent monitor standby & Update README.md 2019-11-03 23:13:55 +01:00
12ea3f83ae Optimizations
- Update README.md
- GameSetCameraPos(x, y) several time while waiting to get all chunks loaded
- Fix tile output coordinates (Top left corner coordinates instead of center)
- Hide some ui parts by default
2019-11-03 00:58:03 +01:00
8cf46232de Several updates
- Add full map capture mode
- Show error on screen if screencapturing failed
- Add additional frame delay for jumps larger than the grid size
- Show progress for map capture mode
- Tweak streaming chunk target
- Update README.md
2019-11-02 21:37:10 +01:00
c87a4d05d0 Several updates
- Change virtual resolution to 1280x720
- Change virtual to screen pixel ratio to 1:1
- Increase grid size to 420
- Make the mod capture only the window (and only the client area)
- Increase STREAMING_CHUNK_TARGET, so that chunks don't unload after some frames
- Add ingame warnings for wrong settings, and information how to fix these
- Update README.md
2019-11-01 02:40:21 +01:00
638f6223c3 Update README.md 2019-10-26 21:50:43 +02:00
d585468f0b Update README.md 2019-10-26 16:10:39 +02:00
704864aee4 Fix travis
The stitch app should be x86-x64, but the previous set variables got overwritten anyways.
2019-10-26 15:54:58 +02:00
acc863c4b1 Update README.md 2019-10-25 23:22:42 +02:00
37d660bb0c Now 2019-10-25 23:01:47 +02:00
5deb48a0fd Another try -__- 2019-10-25 21:34:42 +02:00
e8c8e3e572 Fix travis config 2019-10-25 21:29:19 +02:00
107 changed files with 9966 additions and 1327 deletions

13
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,13 @@
# These are supported funding model platforms
github: Dadido3 # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

44
.github/workflows/build-release.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: Build and release
on:
release:
types: [created]
jobs:
build:
name: Build and release
runs-on: windows-latest
strategy:
matrix:
goos: [windows]
goarch: ["amd64"]
steps:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: ^1.22
- name: Check out code into the Go module directory
uses: actions/checkout@v2
with:
submodules: recursive
- name: Build stitch tool
run: go build -v -ldflags="-X 'main.versionString=${{ github.event.release.tag_name }}'" .
working-directory: ./bin/stitch
env:
GOARCH: ${{ matrix.goarch }}
GOOS: ${{ matrix.goos }}
CGO_ENABLED: 1
- name: Create distribution archive
run: go run -v ./scripts/dist
- name: Upload binary to release
uses: svenstaro/upload-release-action@v2
with:
file: dist/dist.zip
asset_name: noita-mapcap-${{ matrix.goos }}-${{ matrix.goarch }}.zip
overwrite: true

28
.github/workflows/build-test.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: Build and test
on: [push, pull_request]
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: ^1.22
- name: Check out code into the Go module directory
uses: actions/checkout@v2
with:
submodules: recursive
- name: Build stitch tool
run: go build -v .
working-directory: ./bin/stitch
- name: Test stitch tool
run: go test -v .
working-directory: ./bin/stitch

11
.gitignore vendored
View File

@ -103,6 +103,11 @@ $RECYCLE.BIN/
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
/libs/ /output/
output/ /dist/
/distribution /bin/stitch/*.png
/bin/stitch/*.dzi
/bin/stitch/*_files/
/files/magic-numbers/generated.xml
/bin/stitch/captures/*

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "files/libraries/luanxml"]
path = files/libraries/luanxml
url = https://github.com/zatherz/luanxml

View File

@ -1,35 +0,0 @@
language: go
go:
- "1.13.x"
env:
- GO111MODULE=on
matrix:
include:
- os: windows
env:
- GOOS=windows
- GOARCH=386
script:
- cd bin/stitch
- go test
- go build
before_deploy: ./scripts/pack_windows.x86.bat
cache:
directories:
- $HOME/AppData/Local/go-build
- $HOME/gopath/pkg/mod
install: true
deploy:
provider: releases
api_key:
secure: pUvWpsj6oy+F+C71ftozpf7ZhAC808Y1vVaxjql3CUYVZGN1Fx8KnmTJpjZxhLALadCQmp3QrqEZ6uWeamHy66ipPyroM+yHlx4vUqOlE973S/lF7F+VWUCaJBv04nTkPquQrr/quqrpj/1YYthWa7ZaNcqOuhkRd2/VWevPw3vbvwCPd9bWDMcY9gdtXmIiLrkF6eZPo7L4nM759jOG2wIN4W10x57qv9xdVGpLkRKvFcKfEyS0iPXbANdwZcjKesumpyNY3DzgKf8mKOcLrKe/BT7z0CqYT+Z55bwD+TQt94sh5OinPhotKtnEhR+zEN+5EonF4hwHNAfYNl9x2IiEVww4XTdGHN/RK+3GKCNP7raDbvsGimp4egF+7HzTmqBsL6LqkGMX6cUBVLpgu5IL2s98zSGudhYkmo5Dm+WYP1CmHDFSOYj5qUuTbZuQoqoy/yhKLKeEfGJJeO7wyRONypB9Q7S+Rpr4PMrW1pQqdTEHxho9Vr/5HtxP9Z2dWEOlsK9GH9i4eLtt2p0crArgTFruE2qd8eXRfMx8tBL+15hgPoPczhalFvUQnnV0QJ0flnm2uTtJZGz1Tv28qeFz36K4z8wGYyLV5fDhyYV392PbR8SKDlxc9WNifGk979mMbJHKrTzNI781Nczg4kz8rW/0ymWjNycjE6BQJT0=
file:
- "./distribution/Windows.x86.7z"
skip_cleanup: true
draft: true
on:
tags: true

106
.vscode/settings.json vendored
View File

@ -1,21 +1,121 @@
{ {
"cSpell.words": [ "cSpell.words": [
"Lanczos", "aabb",
"Vogel", "acidflow",
"appdata",
"autosetup",
"backbuffer",
"basicfont",
"bytecode",
"cheggaaa",
"Dadido",
"dofile",
"dont",
"Downscales",
"downscaling",
"DPMM",
"executables", "executables",
"framebuffer",
"framebuffers",
"Fullscreen",
"goarch",
"gridify", "gridify",
"hacky", "hacky",
"hilbertify", "hilbertify",
"Hitbox",
"ipairs",
"kbinani", "kbinani",
"Lanczos",
"lann",
"ldflags",
"libwebp",
"linearize",
"longleg",
"lowram",
"luanxml",
"manifoldco", "manifoldco",
"mapcap", "mapcap",
"Metamethods",
"metaobject",
"Metatable",
"nfnt", "nfnt",
"Niccoli",
"noita", "noita",
"noitamap",
"Nolla",
"NXML",
"openseadragon",
"pixelated",
"polymorphed",
"promptui",
"rasterizer",
"Regen",
"respawn",
"runfast",
"savegames",
"schollz", "schollz",
"screenshake",
"svenstaro",
"tcnksm", "tcnksm",
"tdewolff",
"unmodded",
"unstitchable",
"upscaled",
"Vogel",
"Voronoi",
"webp",
"wepb",
"xmax", "xmax",
"xmin", "xmin",
"ymax", "ymax",
"ymin" "ymin",
"Zatherz"
],
"Lua.runtime.version": "LuaJIT",
"Lua.format.defaultConfig": {
"max_line_length": "512"
},
"Lua.workspace.ignoreSubmodules": false,
"cSpell.enabledLanguageIds": [
"asciidoc",
"c",
"cpp",
"csharp",
"css",
"elixir",
"erlang",
"git-commit",
"go",
"graphql",
"handlebars",
"haskell",
"html",
"jade",
"java",
"javascript",
"javascriptreact",
"json",
"jsonc",
"jupyter",
"latex",
"less",
"markdown",
"php",
"plaintext",
"python",
"pug",
"restructuredtext",
"rust",
"scala",
"scss",
"scminput",
"swift",
"text",
"typescript",
"typescriptreact",
"vue",
"yaml",
"yml",
"lua"
] ]
} }

83
AREAS.md Normal file
View File

@ -0,0 +1,83 @@
# 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.
The dimensions of the capture rectangle are exactly:
``` lua
Width = Right - Left
Height = Bottom - Top
```
Depending on the virtual resolution you use, the resulting capture may be a bit larger.
If you need the output to have exact dimensions, supply your needed coordinates to the stitcher tool.
Coordinate system:
![Coordinate system](images/coordinates.png)
## `Base layout`
This area features only the "base layout".
Everything around this area uses a similar layout, but with different materials and a different seed.
``` lua
Left = -17920
Top = -7168
Right = 17920
Bottom = 17408
```
The end result will have a size of `35840 x 24576 pixels ~= 880 megapixels`.
![Base layout](images/scale32_base-layout.png)
## `Main world`
This area features only the "base layout" plus the area above (sky) and below (hell).
It totals to a height of exactly 3 times the base layout's height.
``` lua
Left = -17920
Top = -31744
Right = 17920
Bottom = 41984
```
The end result will have a size of `35840 x 73728 pixels ~= 2642 megapixels`.
![Base layout](images/scale32_main-world.png)
## `Extended`
This area consists of `Main world` plus fractions of the left and right parallel worlds.
``` lua
Left = -25600
Top = -31744
Right = 25600
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

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2019 David Vogel Copyright (c) 2019-2023 David Vogel
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

227
README.md
View File

@ -1,28 +1,223 @@
# Noita MapCapture addon # Noita map capture addon
Addon that captures the map and saves it as image. A mod for Noita that can capture images of the world and stitch them into one large image.
It works with the regular Noita build and the dev build.
![](images/example1.png) ![Title image](images/title.png)
A resulting image with close to 3 gigapixels can be [seen here](https://easyzoom.com/image/158284/album/0/4). 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
- Windows Vista, ..., 10, 11. (64 bit OS for stitching)
- A few GB of free drive space.
- 4 or more GB of RAM for gigapixel images. (But it works with less as long as the software doesn't run out of virtual memory)
## Installation
1. Have Noita installed.
2. Download the [latest release of the mod from this link](https://github.com/Dadido3/noita-mapcap/releases/latest) (The `noita-mapcap-windows-amd64.zip`, not the source code)
3. Unpack it into your mods folder, so that you get the following file structure `.../Noita/mods/noita-mapcap/mod.xml`.
You can open the mods folder by clicking `Open mod folder` from within the Noita mod menu.
4. Refresh the mod list.
5. Enable the `MapCapture` mod.
## Usage ## Usage
1. Have Noita beta installed You can use the mod with either the regular Noita version, or the dev build `noita_dev.exe` that is located in the game installation directory.
2. Install the repository as mod Using `noita_dev.exe` has the advantage that you can freeze pixel and rigid body simulations. Also, it uses a different location for its savegames, which means you don't have to worry about any save you may have left unfinished on the regular build.
- mod.xml and the rest should be in `.../Noita/mods/noita-mapcap/`
3. Enable mod, and restart Noita
4. In the game you should see a `Start capturing map` text on the screen, click it
5. The screen will jump around, and the game will take screenshots automatically. Don't interfere with it. Screenshots are saved in `.../Noita/mods/noita-mapcap/output/`
6. When you think you are done, close noita
7. Start `.../Noita/mods/noita-mapcap/bin/stitch/stitch.exe`
- It will take the screenshots from the `output` folder
8. An `output.png` with the stitched result will appear
## Advanced usage Every setting you want or need to change can be found inside the `mod settings` tab of the game options.
By default the mod settings will be set to useful values.
An explanation for every setting can be found in the [Mod settings](#mod-settings) section.
If you use `noita_dev.exe`, you can enable the debug mode by pressing `F5`. Once in debug mode, you can use `F8` to toggle shaders (Includes fog of war), and you can use `F12` to disable the UI. There are some more options in the `F7` and `Shift + F7` menu. Once you have changed the mod settings to your liking you can start or resume a game with the mod enabled.
You may see message boxes that suggest actions.
This can happen if the mod detects game settings that do not align with what you have set in the mod settings.
All you need to do is follow the given instructions, like:
![Auto setup requester example](images/requester-autosetup.png)
> ![Hint](files/ui-gfx/hint-16x16.png) Most of the changes this mod does to Noita are non permanent and will be gone after a restart or a new game, except:
>
> - Window, internal and virtual resolutions, if requested.
> - Screen shake intensity, which will always be disabled.
>
> You can always *right* click ![Record button](files/ui-gfx/record-16x16.png) to reset the above mentioned settings back to Noita's default.
After all issues have been resolved you are free to start capturing.
To the top left of the window are 3 buttons:
- ![Record button](files/ui-gfx/record-16x16.png)/![Stop button](files/ui-gfx/stop-16x16.png) Starts/Stops the capturing process based on your mod settings.
You can always restart a capture, and it will resume where it was stopped.
- ![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.
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.
After a few minutes the file `output.png` will be created.
> ![Hint](files/ui-gfx/hint-16x16.png) See [stitcher/README.md](bin/stitch/README.md) for more information about all stitcher parameters.
## Mod settings
> ![Hint](files/ui-gfx/hint-16x16.png) Use *right* mouse button to reset any mod setting to their default.
- `Mode`: Defines what the mod captures, and how it captures it:
- `Live`: The mod will capture as you play along.
The end result is a map with the path of your run.
- `Area`: Captures a defined rectangle of the world.
You can either use [predefined areas](AREAS.md), or enter custom coordinates.
- `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.
- `Grid size`: The amount of world pixels the viewport will move between the screenshots.
- `Pixel scale`: The resulting pixel size of the screenshots.
If greater than 0, all screenshots will be rescaled to have the given pixel size.
- `Use custom resolution`: If enabled, the mod will change the game resolutions to the given values.
- `Capture interval`: Interval between screen captures, when in live mode.
- `Min. capture distance`: The distance in world pixels the viewport has to move to allow another screenshot.
Only used in live mode.
- `Max. capture distance`: The distance in world pixels the viewport has to move to force another screenshot.
Only used in live mode.
- `Capture entities`: If enabled, the mod will capture all entities, their children, parameters and components and write them into `output/entities.json`.
> ![Warning](files/ui-gfx/warning-16x16.png) This can slow down capturing a bit, it may also make Noita more unstable.
- `Disable parallax background`: Will replace the world background with black pixels.
- `Disable UI`: Will disable inventory UI.
But the UI can still appear if triggered by mouse wheel or something similar.
- `Disable pixel and entity physics`: Will disable/stop pixel and rigid body simulation.
Only works in dev build.
- `Disable post FX`: Disables most postprocessing effects like:
- Dithering
- Refraction
- Lighting
- Fog of war
- Glow
- Gamma correction
- `Disable shaders, GUI and AI`: Also disables all postprocessing, any in-game UI and will freeze all mobs.
Only works in dev build.
- `Disable entity logic`: Will modify all encountered entities:
- Disables AI
- Disables falling
- Disables hovering and rotation animations
- Reduces explosions
> ![Warning](files/ui-gfx/warning-16x16.png) This can slow down capturing a bit, it may also make Noita more unstable.
### Example settings
Use these settings if you want to capture your in-game action.
The sliders are at their default values:
![Live capture example settings](images/mod-settings-live.png)
Use these settings to capture the [base layout](AREAS.md#base-layout) with the least amount of glitches and artifacts.
The sliders are at their default values:
![Area capture example settings](images/mod-settings-area.png)
## Troubleshooting
### Noita crashes a lot
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] 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.
More information/details about this can be found [here](https://github.com/Dadido3/noita-mapcap/issues/7#issuecomment-723571110).
### I get "ASSERT FAILED!" messages
These can't really be prevented.
All you can do is to click `Ignore always`.
Alternatively you can run the same capture in the regular Noita (non dev build), which has these messages disabled.
With the exception that you can't disable the pixel and rigid body simulations, the mod works just as well as in the dev build.
### The mod messed up my game
Custom resolution settings are retained even if you restart Noita or start a new game.
In the worst case they will cause the game world to not render correctly, or they will make your game only use a fraction of the window.
To reset any permanent settings that may have been set by the mod:
1. Enable the mod.
2. Start a new game.
3. *Right* click ![Record button](files/ui-gfx/record-16x16.png) and follow instructions.
> ![Hint](files/ui-gfx/hint-16x16.png) If you have changed any resolutions in your game's `config.xml`, you may have to re-apply these changes.
> This also applies if you use any mods that makes Noita work on ultra-wide screens.
> For these mods to work again after a reset, you need to go through their installation steps again.
Alternatively, you can reset **all** game settings by deleting:
- `"%appdata%\..\LocalLow\Nolla_Games_Noita\save_shared\config.xml"` for the regular Noita.
- `"...\Noita\save_shared\config.xml"` for the dev build.
### The objects in the stitched image are blurry
The stitcher uses median blending to remove any single frame artifacts and to correct for not rendered chunks.
This will cause fast moving objects to completely disappear, and slow moving objects to get blurry.
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.
## 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] 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 ## License
[MIT](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 David Vogel ; Copyright (c) 2019-2024 David Vogel
; ;
; This software is released under the MIT License. ; This software is released under the MIT License.
; https://opensource.org/licenses/MIT ; https://opensource.org/licenses/MIT
EnableExplicit
UsePNGImageEncoder() UsePNGImageEncoder()
Declare Worker(*Dummy) Declare Worker(*Dummy)
@ -11,23 +13,68 @@ Structure QueueElement
img.i img.i
x.i x.i
y.i y.i
sx.i
sy.i
EndStructure EndStructure
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
; Returns the size of the main OpenGL rendering output as a windows RECT.
ProcedureDLL GetRect(*rect.RECT)
If Not *rect
ProcedureReturn #False
EndIf
Protected dims.GLViewportDims
glGetIntegerv_(#GL_VIEWPORT, dims)
*rect\left = dims\x
*rect\top = dims\y
*rect\right = dims\x + dims\width
*rect\bottom = dims\y + dims\height
ProcedureReturn #True
EndProcedure
ProcedureDLL AttachProcess(Instance) ProcedureDLL AttachProcess(Instance)
Global Semaphore = CreateSemaphore() Global Semaphore = CreateSemaphore()
Global Mutex = CreateMutex() Global Mutex = CreateMutex()
Global NewList Queue.QueueElement() Global NewList Queue.QueueElement()
ExamineDesktops()
CreateDirectory("mods/noita-mapcap/output/") CreateDirectory("mods/noita-mapcap/output/")
For i = 1 To 4 Static Dim WorkerInfos.WorkerInfo(#Workers-1)
CreateThread(@Worker(), #Null) Protected i
For i = 0 To #Workers-1
WorkerInfos(i)\workerNumber = i
CreateThread(@Worker(), @WorkerInfos(i))
Next Next
EndProcedure EndProcedure
Procedure Worker(*Dummy) Procedure Worker(*workerInfo.WorkerInfo)
Protected img, x, y Protected img, x, y, sx, sy
Repeat Repeat
WaitSemaphore(Semaphore) WaitSemaphore(Semaphore)
@ -37,75 +84,106 @@ Procedure Worker(*Dummy)
img = Queue()\img img = Queue()\img
x = Queue()\x x = Queue()\x
y = Queue()\y y = Queue()\y
sx = Queue()\sx
sy = Queue()\sy
DeleteElement(Queue()) DeleteElement(Queue())
UnlockMutex(Mutex) UnlockMutex(Mutex)
SaveImage(img, "mods/noita-mapcap/output/" + x + "," + y + ".png", #PB_ImagePlugin_PNG) If sx > 0 And sy > 0
ResizeImage(img, sx, sy)
EndIf
; 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) FreeImage(img)
ForEver ForEver
EndProcedure EndProcedure
ProcedureDLL Capture(px.i, py.i) ; Takes a screenshot of the client area of this process' active window.
; Get dimensions of main screen ; 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.
x = DesktopX(0) ; sx and sy defines the final dimensions that the screenshot will be resized to. No resize will happen if set to 0.
y = DesktopY(0) ProcedureDLL Capture(*capRect.RECT, x.l, y.l, sx.l, sy.l)
w = DesktopWidth(0) Protected viewportRect.RECT
h = DesktopHeight(0) If Not GetRect(@viewportRect)
ProcedureReturn #False
imageID = CreateImage(#PB_Any, w, h)
If Not imageID
ProcedureReturn
EndIf EndIf
; Get DC of whole screen Protected imageID, hDC, *pixelBuffer
screenDC = GetDC_(#Null)
If Not screenDC ; Limit the desired capture area to the actual client area of the viewport.
FreeImage(imageID) If *capRect\left < 0 : *capRect\left = 0 : EndIf
ProcedureReturn If *capRect\top < 0 : *capRect\top = 0 : 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
Protected capWidth = *capRect\right - *capRect\left
Protected capHeight = *capRect\bottom - *capRect\top
imageID = CreateImage(#PB_Any, capWidth, capHeight)
If Not imageID
ProcedureReturn #False
EndIf EndIf
hDC = StartDrawing(ImageOutput(imageID)) hDC = StartDrawing(ImageOutput(imageID))
If Not hDC If Not hDC
FreeImage(imageID) FreeImage(imageID)
ReleaseDC_(#Null, screenDC) ProcedureReturn #False
ProcedureReturn
EndIf EndIf
If Not BitBlt_(hDC, 0, 0, w, h, screenDC, x, y, #SRCCOPY) ; After some time BitBlt will fail, no idea why. Also, that's moments before noita crashes.
FreeImage(imageID)
ReleaseDC_(#Null, screenDC)
StopDrawing()
ProcedureReturn
EndIf
StopDrawing()
ReleaseDC_(#Null, screenDC) *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) LockMutex(Mutex)
; Check if the queue has too many elements, if so, wait. (Simulate go's channels) ; Check if the queue has too many elements, if so, wait. (Emulate go's channels)
While ListSize(Queue()) > 0 While ListSize(Queue()) > 1
UnlockMutex(Mutex) UnlockMutex(Mutex)
Delay(10) Delay(1)
LockMutex(Mutex) LockMutex(Mutex)
Wend Wend
LastElement(Queue()) LastElement(Queue())
AddElement(Queue()) AddElement(Queue())
Queue()\img = imageID Queue()\img = imageID
Queue()\x = px Queue()\x = x
Queue()\y = py Queue()\y = y
Queue()\sx = sx
Queue()\sy = sy
UnlockMutex(Mutex) UnlockMutex(Mutex)
SignalSemaphore(Semaphore) SignalSemaphore(Semaphore)
ProcedureReturn #True
EndProcedure EndProcedure
; IDE Options = PureBasic 5.71 LTS (Windows - x64) ; #### Test
;AttachProcess(0)
;OpenWindow(0, 100, 200, 195, 260, "PureBasic Window", #PB_Window_SystemMenu | #PB_Window_MinimizeGadget | #PB_Window_MaximizeGadget)
;Delay(1000)
;Capture(123, 123)
;Delay(1000)
; IDE Options = PureBasic 6.04 LTS (Windows - x64)
; ExecutableFormat = Shared dll ; ExecutableFormat = Shared dll
; CursorPosition = 25 ; CursorPosition = 99
; FirstLine = 3 ; FirstLine = 72
; Folding = - ; Folding = -
; Optimizer
; EnableThread ; EnableThread
; EnableXP ; EnableXP
; Executable = capture.dll ; Executable = capture.dll
; DisableDebugger ; DisableDebugger
; Compiler = PureBasic 5.71 LTS (Windows - x86) ; Compiler = PureBasic 6.04 LTS - C Backend (Windows - x86)

Binary file not shown.

View File

@ -1,8 +1,3 @@
rem Copyright (c) 2019 David Vogel
rem
rem This software is released under the MIT License.
rem https://opensource.org/licenses/MIT
set GOARCH=386 set GOARCH=386
set CGO_ENABLED=1 set CGO_ENABLED=1

View File

@ -1,4 +1,4 @@
// Copyright (c) 2019 David Vogel // Copyright (c) 2019-2020 David Vogel
// //
// This software is released under the MIT License. // This software is released under the MIT License.
// https://opensource.org/licenses/MIT // https://opensource.org/licenses/MIT

View File

@ -17,7 +17,7 @@ The source images need to contain their coordinates in the filename, as this pro
example list of files: example list of files:
``` Shell Session ``` Text
0,0.png 0,0.png
512,0.png 512,0.png
-512,0.png -512,0.png
@ -26,13 +26,30 @@ example list of files:
## Usage ## Usage
- Run the program and follow the interactive prompt. - Either run the program and follow the interactive prompt.
- Run the program with parameters: - Or run the program with parameters:
- `divide int` - `divide int`
A downscaling factor. 2 will produce an image with half the side lengths. (default 2) A downscaling factor. 2 will produce an image with half the side lengths. Defaults to 1.
- `input string`The source path of the image tiles to be stitched. (default "..\\..\\output") - `blend-tile-limit int`
Limits median blending to the n newest tiles by file modification time.
If set to 0, all available tiles will be median blended.
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"
- `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` - `output string`
The path and filename of the resulting stitched image. (default "output.png") The path and filename of the resulting stitched image. Defaults to "output.png".
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`
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` - `xmax int`
Right bound of the output rectangle. This coordinate is not included in the output. Right bound of the output rectangle. This coordinate is not included in the output.
- `xmin int` - `xmin int`
@ -42,21 +59,34 @@ example list of files:
- `ymin int` - `ymin int`
Upper bound of the output rectangle. This coordinate is included in the output. Upper bound of the output rectangle. This coordinate is included in the output.
Example of usage: To output the 100x100 area that is centered at the origin use:
``` Shell Session ``` Shell Session
./stitch -divide 2 ./stitch -divide 1 -xmin -50 -xmax 50 -ymin -50 -ymax 50
``` ```
Example of output: To output a [Deep Zoom Image (DZI)](https://en.wikipedia.org/wiki/Deep_Zoom), which can be used with [OpenSeadragon](https://openseadragon.github.io/examples/tilesource-dzi/), use:
``` Shell Session ``` Shell Session
2019/10/25 16:02:25 Starting to read tile information at "..\..\output" ./stitch -output capture.dzi
2019/10/25 16:02:34 Got 43338 tiles ```
2019/10/25 16:02:34 Total size of the possible output space is (-19968,-36864)-(21184,35100)
2019/10/25 16:02:34 Creating output image with a size of (41152,71964) To start the program interactively:
2019/10/25 16:02:46 Stitching 43338 tiles into an image at (-19968,-36864)-(21184,35100)
100% |████████████████████████████████████████| [33m13s:0s] ``` Shell Session
2019/10/25 16:35:59 Creating output file "output.png" ./stitch
2019/10/25 16:44:17 Created output file "output.png" ```
Example output:
``` Shell Session
Enter downscaling factor:1
Enter input path:..\..\output
2019/11/04 23:53:20 Starting to read tile information at "..\..\output"
2019/11/04 23:53:32 Got 20933 tiles
2019/11/04 23:53:32 Total size of the possible output space is (-25620,-36540)-(25620,36540)
Enter output rectangle (xMin,yMin;xMax,yMax):-25620,-36540;25620,36540
Enter output filename and path:output.png
2019/11/04 23:53:35 Creating output file "output.png"
105 / 571 [--------------->____________________________________________________________________] 18.39% 1 p/s ETA 14m0s
``` ```

164
bin/stitch/blend-methods.go Normal file
View File

@ -0,0 +1,164 @@
// Copyright (c) 2022-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"image"
"image/color"
"image/draw"
"math"
"sort"
)
// BlendMethodMedian takes the given tiles and median blends them into destImage.
type BlendMethodMedian struct {
BlendTileLimit int // If larger than 0, limits median blending to the n newest tiles by file modification time.
}
// Draw implements the StitchedImageBlendMethod interface.
func (b BlendMethodMedian) Draw(tiles []*ImageTile, destImage *image.RGBA) {
bounds := destImage.Bounds()
if b.BlendTileLimit > 0 {
// Sort tiles by date.
sort.Slice(tiles, func(i, j int) bool { return tiles[i].modTime.After(tiles[j].modTime) })
}
// List of images corresponding with every tile.
// Can contain empty/nil entries for images that failed to load.
images := []*image.RGBA{}
for _, tile := range tiles {
images = append(images, tile.GetImage())
}
// Create arrays to be reused every pixel.
rListEmpty, gListEmpty, bListEmpty := make([]uint8, 0, len(tiles)), make([]uint8, 0, len(tiles)), make([]uint8, 0, len(tiles))
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
rList, gList, bList := rListEmpty, gListEmpty, bListEmpty
point := image.Point{ix, iy}
count := 0
// Iterate through all images and create a list of colors.
for _, img := range images {
if img != nil {
if point.In(img.Bounds()) {
col := img.RGBAAt(point.X, point.Y)
rList, gList, bList = append(rList, col.R), append(gList, col.G), append(bList, col.B)
count++
// Limit number of tiles to median blend.
// Will be ignored if the blend tile limit is 0.
if count == b.BlendTileLimit {
break
}
}
}
}
switch count {
case 0: // If there were no images to get data from, ignore the pixel.
continue
case 1: // Only a single tile for this pixel.
r, g, b := uint8(rList[0]), uint8(gList[0]), uint8(bList[0])
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
default: // Multiple overlapping tiles, median blend them.
var r, g, b uint8
switch count % 2 {
case 0: // Even.
r = uint8((int(QuickSelectUInt8(rList, count/2-1)) + int(QuickSelectUInt8(rList, count/2))) / 2)
g = uint8((int(QuickSelectUInt8(gList, count/2-1)) + int(QuickSelectUInt8(gList, count/2))) / 2)
b = uint8((int(QuickSelectUInt8(bList, count/2-1)) + int(QuickSelectUInt8(bList, count/2))) / 2)
default: // Odd.
r = QuickSelectUInt8(rList, count/2)
g = QuickSelectUInt8(gList, count/2)
b = QuickSelectUInt8(bList, count/2)
}
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
}
}
}
}
// BlendMethodVoronoi maps every pixel to the tile with the closest center point distance.
// The result is basically a Voronoi partitioning.
type BlendMethodVoronoi struct {
BlendTileLimit int // If larger than 0, limits blending to the n newest tiles by file modification time.
}
// Draw implements the StitchedImageBlendMethod interface.
func (b BlendMethodVoronoi) Draw(tiles []*ImageTile, destImage *image.RGBA) {
bounds := destImage.Bounds()
if b.BlendTileLimit > 0 {
// Sort tiles by date.
sort.Slice(tiles, func(i, j int) bool { return tiles[i].modTime.After(tiles[j].modTime) })
}
// List of images corresponding to the "tiles" list.
// Can contain empty/nil entries for images that failed to load.
images := []*image.RGBA{}
for _, tile := range tiles {
images = append(images, tile.GetImage())
}
// Create color variables reused every pixel.
var col color.RGBA
var centerDistSqrMin int
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
point := image.Point{ix, iy}
count := 0
centerDistSqrMin = math.MaxInt
// Iterate through all images and create a list of colors.
for _, img := range images {
if img != nil {
if point.In(img.Bounds()) {
center := img.Bounds().Min.Add(img.Bounds().Max).Div(2)
centerDiff := point.Sub(center)
distSqr := centerDiff.X*centerDiff.X + centerDiff.Y*centerDiff.Y
if centerDistSqrMin > distSqr {
centerDistSqrMin = distSqr
col = img.RGBAAt(point.X, point.Y)
}
count++
// Limit number of tiles to blend.
// Will be ignored if the blend tile limit is 0.
if count == b.BlendTileLimit {
break
}
}
}
}
// If there were no images to get data from, ignore the pixel.
if count == 0 {
continue
}
col.A = 255
destImage.SetRGBA(ix, iy, col)
}
}
}
// 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)
}
}
}

208
bin/stitch/dzi.go Normal file
View File

@ -0,0 +1,208 @@
// Copyright (c) 2023-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"encoding/json"
"fmt"
"image"
"log"
"os"
"path/filepath"
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/cheggaaa/pb/v3"
)
type DZI struct {
stitchedImage *StitchedImage
fileExtension string
tileSize int // The (maximum) width and height of a tile in pixels, not including the overlap.
overlap int // The amount of additional pixels on every side of every tile. The real (max) width/height of an image is `2*overlap + tileSize`.
maxZoomLevel int // The maximum zoom level that is needed.
}
// NewDZI creates a new DZI from the given StitchedImages.
//
// dziTileSize and dziOverlap define the size and overlap of the resulting DZI tiles.
func NewDZI(stitchedImage *StitchedImage, dziTileSize, dziOverlap int) DZI {
dzi := DZI{
stitchedImage: stitchedImage,
fileExtension: ".webp",
overlap: dziOverlap,
tileSize: dziTileSize,
}
width, height := stitchedImage.bounds.Dx(), stitchedImage.bounds.Dy()
// Calculate max zoom level and stuff.
neededLength := max(width, height)
var sideLength int = 1
var level int
for sideLength < neededLength {
level += 1
sideLength *= 2
}
dzi.maxZoomLevel = level
//dzi.maxZoomLevelLength = sideLength
return dzi
}
// ExportDZIDescriptor exports the descriptive JSON file at the given path.
func (d DZI) ExportDZIDescriptor(outputPath string) error {
log.Printf("Creating DZI descriptor %q.", outputPath)
f, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer f.Close()
// Prepare data that describes the layout of the image files.
var dziDescriptor struct {
Image struct {
XMLNS string `json:"xmlns"`
Format string
Overlap string
TileSize string
Size struct {
Width string
Height string
}
TopLeft struct {
X string
Y string
}
}
}
dziDescriptor.Image.XMLNS = "http://schemas.microsoft.com/deepzoom/2008"
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, 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.
// The current stitched image we are working with.
stitchedImage := d.stitchedImage
for zoomLevel := d.maxZoomLevel; zoomLevel >= 0; zoomLevel-- {
levelBasePath := filepath.Join(outputDir, fmt.Sprintf("%d", zoomLevel))
if err := os.MkdirAll(levelBasePath, 0755); err != nil {
return fmt.Errorf("failed to create zoom level base directory %q: %w", levelBasePath, err)
}
// Store list of tiles, so that we can reuse them in the next step for the smaller zoom level.
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)
rect = rect.Add(stitchedImage.bounds.Min)
rect = rect.Inset(-d.overlap)
img := stitchedImage.SubStitchedImage(rect)
filePath := filepath.Join(levelBasePath, fmt.Sprintf("%d_%d%s", iX, iY, d.fileExtension))
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(),
scaleDivider: scaleDivider,
image: image.Rect(DivideFloor(img.Bounds().Min.X, scaleDivider), DivideFloor(img.Bounds().Min.Y, scaleDivider), DivideCeil(img.Bounds().Max.X, scaleDivider), DivideCeil(img.Bounds().Max.Y, scaleDivider)),
imageMutex: &sync.RWMutex{},
invalidationChan: make(chan struct{}, 1),
timeoutChan: make(chan struct{}, 1),
})
}
}
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(), BlendMethodFast{}, 128, nil)
if err != nil {
return fmt.Errorf("failed to run NewStitchedImage(): %w", err)
}
}
return nil
}

63
bin/stitch/entities.go Normal file
View File

@ -0,0 +1,63 @@
// Copyright (c) 2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"encoding/json"
"image"
"os"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/rasterizer"
)
type Entities []Entity
func LoadEntities(path string) (Entities, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
var result Entities
jsonDec := json.NewDecoder(file)
if err := jsonDec.Decode(&result); err != nil {
return nil, err
}
return result, nil
}
// Draw implements the StitchedImageOverlay interface.
func (e Entities) Draw(destImage *image.RGBA) {
destRect := destImage.Bounds()
// Same as destImage, but top left is translated to (0, 0).
originImage := destImage.SubImage(destRect).(*image.RGBA)
originImage.Rect = originImage.Rect.Sub(destRect.Min)
c := canvas.New(float64(destRect.Dx()), float64(destRect.Dy()))
ctx := canvas.NewContext(c)
ctx.SetCoordSystem(canvas.CartesianIV)
ctx.SetCoordRect(canvas.Rect{X: -float64(destRect.Min.X), Y: -float64(destRect.Min.Y), W: float64(destRect.Dx()), H: float64(destRect.Dy())}, float64(destRect.Dx()), float64(destRect.Dy()))
// Set drawing style.
ctx.Style = playerPathDisplayStyle
for _, entity := range e {
// Check if entity origin is near or around the current image rectangle.
entityOrigin := image.Point{int(entity.Transform.X), int(entity.Transform.Y)}
if entityOrigin.In(destRect.Inset(-512)) {
entity.Draw(ctx)
}
}
// Theoretically we would need to linearize imgRGBA first, but DefaultColorSpace assumes that the color space is linear already.
r := rasterizer.FromImage(originImage, canvas.DPMM(1.0), canvas.DefaultColorSpace)
c.RenderTo(r)
r.Close() // This just transforms the image's luminance curve back from linear into non linear.
}

220
bin/stitch/entity.go Normal file
View File

@ -0,0 +1,220 @@
// Copyright (c) 2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"image/color"
"github.com/tdewolff/canvas"
)
//var entityDisplayFontFamily = canvas.NewFontFamily("times")
//var entityDisplayFontFace *canvas.FontFace
var entityDisplayAreaDamageStyle = canvas.Style{
Fill: canvas.Paint{Color: color.RGBA{100, 0, 0, 100}},
Stroke: canvas.Paint{},
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayMaterialAreaCheckerStyle = canvas.Style{
Fill: canvas.Paint{Color: color.RGBA{0, 0, 127, 127}},
Stroke: canvas.Paint{},
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayTeleportStyle = canvas.Style{
Fill: canvas.Paint{Color: color.RGBA{0, 127, 0, 127}},
Stroke: canvas.Paint{},
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayHitBoxStyle = canvas.Style{
Fill: canvas.Paint{Color: color.RGBA{64, 64, 0, 64}},
Stroke: canvas.Paint{Color: color.RGBA{0, 0, 0, 64}},
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
var entityDisplayCollisionTriggerStyle = canvas.Style{
Fill: canvas.Paint{Color: color.RGBA{0, 64, 64, 64}},
Stroke: canvas.Paint{Color: color.RGBA{0, 0, 0, 64}},
StrokeWidth: 1.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
func init() {
//fontName := "NimbusRoman-Regular"
//if err := entityDisplayFontFamily.LoadLocalFont(fontName, canvas.FontRegular); err != nil {
// log.Printf("Couldn't load font %q: %v", fontName, err)
//}
//entityDisplayFontFace = entityDisplayFontFamily.Face(48.0, canvas.White, canvas.FontRegular, canvas.FontNormal)
}
type Entity struct {
Filename string `json:"filename"`
Transform EntityTransform `json:"transform"`
Children []Entity `json:"children"`
Components []Component `json:"components"`
Name string `json:"name"`
Tags []string `json:"tags"`
}
type EntityTransform struct {
X float32 `json:"x"`
Y float32 `json:"y"`
ScaleX float32 `json:"scaleX"`
ScaleY float32 `json:"scaleY"`
Rotation float32 `json:"rotation"`
}
type Component struct {
TypeName string `json:"typeName"`
Members map[string]any `json:"members"`
}
func (e Entity) Draw(c *canvas.Context) {
x, y := float64(e.Transform.X), float64(e.Transform.Y)
for _, component := range e.Components {
switch component.TypeName {
case "AreaDamageComponent": // Area damage like in cursed rock.
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["aabb_min"]; ok {
if aabbMin, ok := member.([]any); ok && len(aabbMin) == 2 {
aabbMinX, _ = aabbMin[0].(float64)
aabbMinY, _ = aabbMin[1].(float64)
}
}
if member, ok := component.Members["aabb_max"]; ok {
if aabbMax, ok := member.([]any); ok && len(aabbMax) == 2 {
aabbMaxX, _ = aabbMax[0].(float64)
aabbMaxY, _ = aabbMax[1].(float64)
}
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayAreaDamageStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
if member, ok := component.Members["circle_radius"]; ok {
if radius, ok := member.(float64); ok && radius > 0 {
// Theoretically we need to clip the damage area to the intersection of the AABB and the circle, but meh.
// TODO: Clip the area to the intersection of the box and the circle
cx, cy := (aabbMinX+aabbMaxX)/2, (aabbMinY+aabbMaxY)/2
c.Style = entityDisplayAreaDamageStyle
c.DrawPath(x+cx, y+cy, canvas.Circle(radius))
}
}
case "MaterialAreaCheckerComponent": // Checks for materials in the given AABB.
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["area_aabb"]; ok {
if aabb, ok := member.([]any); ok && len(aabb) == 4 {
aabbMinX, _ = aabb[0].(float64)
aabbMinY, _ = aabb[1].(float64)
aabbMaxX, _ = aabb[2].(float64)
aabbMaxY, _ = aabb[3].(float64)
}
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayMaterialAreaCheckerStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
case "TeleportComponent":
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["source_location_camera_aabb"]; ok {
if aabb, ok := member.([]any); ok && len(aabb) == 4 {
aabbMinX, _ = aabb[0].(float64)
aabbMinY, _ = aabb[1].(float64)
aabbMaxX, _ = aabb[2].(float64)
aabbMaxY, _ = aabb[3].(float64)
}
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayTeleportStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
case "HitboxComponent": // General hit box component.
var aabbMinX, aabbMinY, aabbMaxX, aabbMaxY float64
if member, ok := component.Members["aabb_min_x"]; ok {
aabbMinX, _ = member.(float64)
}
if member, ok := component.Members["aabb_min_y"]; ok {
aabbMinY, _ = member.(float64)
}
if member, ok := component.Members["aabb_max_x"]; ok {
aabbMaxX, _ = member.(float64)
}
if member, ok := component.Members["aabb_max_y"]; ok {
aabbMaxY, _ = member.(float64)
}
if aabbMinX < aabbMaxX && aabbMinY < aabbMaxY {
c.Style = entityDisplayHitBoxStyle
c.DrawPath(x+aabbMinX, y+aabbMinY, canvas.Rectangle(aabbMaxX-aabbMinX, aabbMaxY-aabbMinY))
}
case "CollisionTriggerComponent": // Checks if another entity is inside the given radius and box with the given width and height.
var width, height float64
path := &canvas.Path{}
if member, ok := component.Members["width"]; ok {
width, _ = member.(float64)
}
if member, ok := component.Members["height"]; ok {
height, _ = member.(float64)
}
if width > 0 && height > 0 {
path = canvas.Rectangle(width, height).Translate(-width/2, -height/2)
}
// Theoretically we need to clip the area to the intersection of the box and the circle, but meh.
// TODO: Clip the area to the intersection of the box and the circle
//if member, ok := component.Members["radius"]; ok {
// if radius, ok := member.(float64); ok && radius > 0 {
// path = path.Append(canvas.Circle(radius))
// path.And()
// }
//}
if !path.Empty() {
c.Style = entityDisplayCollisionTriggerStyle
c.DrawPath(x, y, path)
}
}
}
c.SetFillColor(color.RGBA{255, 255, 255, 128})
c.SetStrokeColor(color.RGBA{255, 0, 0, 255})
c.DrawPath(x, y, canvas.Circle(3))
//text := canvas.NewTextLine(entityDisplayFontFace, fmt.Sprintf("%s\n%s", e.Name, e.Filename), canvas.Left)
//c.DrawText(x, y, text)
}

40
bin/stitch/export-dzi.go Normal file
View File

@ -0,0 +1,40 @@
// Copyright (c) 2023-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cheggaaa/pb/v3"
)
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"
dzi := NewDZI(stitchedImage, dziTileSize, dziOverlap)
// Create base directory of all DZI files.
if err := os.MkdirAll(outputTilesPath, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Export DZI descriptor.
if err := dzi.ExportDZIDescriptor(descriptorPath); err != nil {
return fmt.Errorf("failed to export DZI descriptor: %w", err)
}
// Export DZI tiles.
if err := dzi.ExportDZITiles(outputTilesPath, bar, webPLevel); err != nil {
return fmt.Errorf("failed to export DZI tiles: %w", err)
}
return nil
}

67
bin/stitch/export-jpeg.go Normal file
View File

@ -0,0 +1,67 @@
// Copyright (c) 2023-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
"image/jpeg"
"log"
"os"
"time"
"github.com/cheggaaa/pb/v3"
)
func exportJPEGStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar) 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 exportJPEG(stitchedImage, outputPath)
}
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)
}
defer f.Close()
options := &jpeg.Options{
Quality: 80,
}
if err := jpeg.Encode(f, img, options); err != nil {
return fmt.Errorf("failed to encode image %q: %w", outputPath, err)
}
return nil
}

67
bin/stitch/export-png.go Normal file
View File

@ -0,0 +1,67 @@
// Copyright (c) 2023-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
"image/png"
"log"
"os"
"time"
"github.com/cheggaaa/pb/v3"
)
func exportPNGStitchedImage(stitchedImage *StitchedImage, outputPath string, bar *pb.ProgressBar) 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 exportPNG(stitchedImage, outputPath)
}
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)
}
defer f.Close()
encoder := png.Encoder{
CompressionLevel: png.DefaultCompression,
}
if err := encoder.Encode(f, img); err != nil {
return fmt.Errorf("failed to encode image %q: %w", outputPath, err)
}
return nil
}

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
}

216
bin/stitch/image-tile.go Normal file
View File

@ -0,0 +1,216 @@
// Copyright (c) 2019-2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
"image/draw"
_ "image/png"
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"sync"
"time"
"github.com/nfnt/resize"
)
var ImageTileFileRegex = regexp.MustCompile(`^(-?\d+),(-?\d+).png$`)
type ImageTile struct {
fileName string
modTime time.Time
scaleDivider int // Downscales the coordinates and images on the fly.
image image.Image // Either a rectangle or an RGBA image. The bounds of this image are determined by the filename.
imageMutex *sync.RWMutex
invalidationChan chan struct{} // Used to send invalidation requests to the tile's goroutine.
timeoutChan chan struct{} // Used to determine whether the tile is still being accessed or not.
}
// NewImageTile returns an image tile object that represents the image at the given path.
// The filename will be used to determine the top left x and y coordinate of the tile.
// This will not load the image into RAM.
func NewImageTile(path string, scaleDivider int) (ImageTile, error) {
if scaleDivider < 1 {
return ImageTile{}, fmt.Errorf("invalid scale of %v", scaleDivider)
}
baseName := filepath.Base(path)
result := ImageTileFileRegex.FindStringSubmatch(baseName)
var x, y int
if parsed, err := strconv.ParseInt(result[1], 10, 0); err == nil {
x = int(parsed)
} else {
return ImageTile{}, fmt.Errorf("error parsing %q to integer: %w", result[1], err)
}
if parsed, err := strconv.ParseInt(result[2], 10, 0); err == nil {
y = int(parsed)
} else {
return ImageTile{}, fmt.Errorf("error parsing %q to integer: %w", result[2], err)
}
width, height, err := GetImageFileDimension(path)
if err != nil {
return ImageTile{}, err
}
var modTime time.Time
fileInfo, err := os.Lstat(path)
if err == nil {
modTime = fileInfo.ModTime()
}
return ImageTile{
fileName: path,
modTime: modTime,
scaleDivider: scaleDivider,
image: image.Rect(DivideFloor(x, scaleDivider), DivideFloor(y, scaleDivider), DivideCeil(x+width, scaleDivider), DivideCeil(y+height, scaleDivider)),
imageMutex: &sync.RWMutex{},
invalidationChan: make(chan struct{}, 1),
timeoutChan: make(chan struct{}, 1),
}, nil
}
// GetImage returns an image.Image that contains the tile pixel data.
// This will not return errors in case something went wrong, but will just return nil.
// All errors are written to stdout.
func (it *ImageTile) GetImage() *image.RGBA {
it.imageMutex.RLock()
// Clear the timeout chan to signal that the image is still being used.
select {
case <-it.timeoutChan:
default:
}
// Check if the image is already loaded.
if img, ok := it.image.(*image.RGBA); ok {
it.imageMutex.RUnlock()
return img
}
it.imageMutex.RUnlock()
// It's possible that the image got changed in between here.
it.imageMutex.Lock()
defer it.imageMutex.Unlock()
// Check again if the image is already loaded.
if img, ok := it.image.(*image.RGBA); ok {
return img
}
// Store rectangle of the old image.
oldRect := it.image.Bounds()
file, err := os.Open(it.fileName)
if err != nil {
log.Printf("Couldn't load file %q: %v.", it.fileName, err)
return nil
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
log.Printf("Couldn't decode image %q: %v.", it.fileName, err)
return nil
}
if it.scaleDivider > 1 {
img = resize.Resize(uint(oldRect.Dx()), uint(oldRect.Dy()), img, resize.NearestNeighbor)
}
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
}
imgRGBA.Rect = imgRGBA.Rect.Add(oldRect.Min)
it.image = imgRGBA
// Clear any old invalidation request.
select {
case <-it.invalidationChan:
default:
}
// Fill timeout channel with one element.
// This is needed, as the ticker doesn't send a tick on initialization.
select {
case it.timeoutChan <- struct{}{}:
default:
}
// Free the image after some time or if requested externally.
go func() {
// Set up watchdog that checks if the image is being used.
ticker := time.NewTicker(5000 * time.Millisecond)
defer ticker.Stop()
loop:
for {
select {
case <-ticker.C:
// Try to send to the timeout channel.
select {
case it.timeoutChan <- struct{}{}:
default:
// Timeout channel is full because the tile image wasn't queried recently.
break loop
}
case <-it.invalidationChan:
// An invalidation was requested externally.
break loop
}
}
// Free image and other stuff.
it.imageMutex.Lock()
defer it.imageMutex.Unlock()
it.image = it.image.Bounds()
}()
return imgRGBA
}
// Clears the cached image.
func (it *ImageTile) Invalidate() {
it.imageMutex.RLock()
defer it.imageMutex.RUnlock()
// Try to send invalidation request.
select {
case it.invalidationChan <- struct{}{}:
default:
}
}
// The scaled image boundaries.
// This matches exactly to what GetImage() returns.
func (it *ImageTile) Bounds() image.Rectangle {
it.imageMutex.RLock()
defer it.imageMutex.RUnlock()
return it.image.Bounds()
}
func (it *ImageTile) String() string {
return fmt.Sprintf("{ImageTile: %q}", it.fileName)
}

63
bin/stitch/image-tiles.go Normal file
View File

@ -0,0 +1,63 @@
// Copyright (c) 2019-2023 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
"path/filepath"
)
type ImageTiles []ImageTile
// LoadImageTiles "loads" all images in the directory at the given path.
func LoadImageTiles(path string, scaleDivider int) (ImageTiles, error) {
if scaleDivider < 1 {
return nil, fmt.Errorf("invalid scale of %v", scaleDivider)
}
var imageTiles ImageTiles
files, err := filepath.Glob(filepath.Join(path, "*.png"))
if err != nil {
return nil, err
}
for _, file := range files {
imageTile, err := NewImageTile(file, scaleDivider)
if err != nil {
return nil, err
}
imageTiles = append(imageTiles, imageTile)
}
return imageTiles, nil
}
// InvalidateAboveY invalidates all cached images that have no pixel at the given y coordinate or below.
func (it ImageTiles) Bounds() image.Rectangle {
totalBounds := image.Rectangle{}
for i, tile := range it {
if i == 0 {
totalBounds = tile.Bounds()
} else {
totalBounds = totalBounds.Union(tile.Bounds())
}
}
return totalBounds
}
// InvalidateAboveY invalidates all cached images that have no pixel at the given y coordinate or below.
func (it ImageTiles) InvalidateAboveY(y int) {
for i := range it {
tile := &it[i] // Need to copy a reference.
if tile.Bounds().Max.Y <= y {
tile.Invalidate()
}
}
}

View File

@ -1,111 +0,0 @@
// Copyright (c) 2019 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
_ "image/png"
"os"
"sync"
"time"
"github.com/nfnt/resize"
)
type imageTile struct {
fileName string
scaleDivider int // Downscales the coordinates and images on the fly.
offset image.Point // Correction offset of the image, so that it aligns pixel perfect with other images. Determined by image matching.
image image.Image // Either a rectangle or an RGBA image. The bounds of this image are determined by the filename.
imageMutex *sync.RWMutex //
imageUsedFlag bool // Flag signalling, that the image was used recently
}
func (it *imageTile) GetImage() (*image.RGBA, error) {
it.imageMutex.RLock()
it.imageUsedFlag = true // Race condition may happen on this flag, but doesn't matter here.
// Check if the image is already loaded
if img, ok := it.image.(*image.RGBA); ok {
it.imageMutex.RUnlock()
return img, nil
}
it.imageMutex.RUnlock()
// It's possible that the image got changed in between here
it.imageMutex.Lock()
defer it.imageMutex.Unlock()
// Check again if the image is already loaded
if img, ok := it.image.(*image.RGBA); ok {
return img, nil
}
// Store rectangle of the old image
oldRect := it.image.Bounds()
file, err := os.Open(it.fileName)
if err != nil {
return &image.RGBA{}, err
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
return &image.RGBA{}, err
}
if it.scaleDivider > 1 {
img = resize.Resize(uint(oldRect.Dx()), uint(oldRect.Dy()), img, resize.NearestNeighbor)
}
imgRGBA, ok := img.(*image.RGBA)
if !ok {
return &image.RGBA{}, fmt.Errorf("Expected an RGBA image, got %T instead", img)
}
// Restore the position of the image rectangle
imgRGBA.Rect = imgRGBA.Rect.Add(oldRect.Min)
it.image = imgRGBA
// Free the image after some time
go func() {
for it.imageUsedFlag {
time.Sleep(1 * time.Second)
it.imageUsedFlag = false
}
it.imageMutex.Lock()
defer it.imageMutex.Unlock()
it.image = it.image.Bounds()
}()
return imgRGBA, nil
}
func (it *imageTile) OffsetBounds() image.Rectangle {
it.imageMutex.RLock()
defer it.imageMutex.RUnlock()
return it.image.Bounds().Add(it.offset)
}
func (it *imageTile) Bounds() image.Rectangle {
it.imageMutex.RLock()
defer it.imageMutex.RUnlock()
return it.image.Bounds()
}
func (it *imageTile) String() string {
return fmt.Sprintf("<ImageTile \"%v\">", it.fileName)
}

View File

@ -1,356 +0,0 @@
// Copyright (c) 2019 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
"image/color"
"log"
"math"
"math/rand"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"sync"
"github.com/schollz/progressbar/v2"
)
const tileAlignmentSearchRadius = 5
type tileAlignment struct {
offset image.Point // Contains the offset of the tile a, so that it aligns pixel perfect with tile b
}
type tileAlignmentKeys struct {
a, b *imageTile
}
// tilePairs contains image pairs and their alignment.
type tilePairs map[tileAlignmentKeys]tileAlignment
var regexFileParse = regexp.MustCompile(`^(-?\d+),(-?\d+).png$`)
func loadImages(path string, scaleDivider int) ([]imageTile, error) {
var imageTiles []imageTile
if scaleDivider < 1 {
return nil, fmt.Errorf("Invalid scale of %v", scaleDivider)
}
files, err := filepath.Glob(filepath.Join(path, "*.png"))
if err != nil {
return nil, err
}
for _, file := range files {
baseName := filepath.Base(file)
result := regexFileParse.FindStringSubmatch(baseName)
var x, y int
if parsed, err := strconv.ParseInt(result[1], 10, 0); err == nil {
x = int(parsed)
} else {
return nil, fmt.Errorf("Error parsing %v to integer: %w", result[1], err)
}
if parsed, err := strconv.ParseInt(result[2], 10, 0); err == nil {
y = int(parsed)
} else {
return nil, fmt.Errorf("Error parsing %v to integer: %w", result[2], err)
}
width, height, err := getImageFileDimension(file)
if err != nil {
return nil, err
}
imageTiles = append(imageTiles, imageTile{
fileName: file,
scaleDivider: scaleDivider,
image: image.Rect(x/scaleDivider, y/scaleDivider, (x+width)/scaleDivider, (y+height)/scaleDivider),
imageMutex: &sync.RWMutex{},
})
}
return imageTiles, nil
}
// AlignTilePair returns the pixel delta for the first tile, so that it aligns perfectly with the second.
// This function will load images if needed.
func AlignTilePair(tileA, tileB *imageTile, searchRadius int) (image.Point, error) {
imgA, err := tileA.GetImage()
if err != nil {
return image.Point{}, err
}
imgB, err := tileB.GetImage()
if err != nil {
return image.Point{}, err
}
bestPoint := image.Point{}
bestValue := math.Inf(1)
for y := -searchRadius; y <= searchRadius; y++ {
for x := -searchRadius; x <= searchRadius; x++ {
point := image.Point{x, y} // Offset of the first image.
value := getImageDifferenceValue(imgA, imgB, point)
if bestValue > value {
bestValue, bestPoint = value, point
}
}
}
return bestPoint, nil
}
func (tp tilePairs) AlignTiles(tiles []*imageTile) error {
n := len(tiles)
maxOperations, operations := (n-1)*(n)/2, 0
// Compare all n tiles with each other. (`(n-1)*(n)/2` comparisons)
for i, tileA := range tiles {
for j := i + 1; j < len(tiles); j++ {
tileB := tiles[j]
_, ok := tp[tileAlignmentKeys{tileA, tileB}]
if !ok {
// Entry doesn't exist yet. Determine tile pair alignment.
offset, err := AlignTilePair(tileA, tileB, tileAlignmentSearchRadius)
if err != nil {
return fmt.Errorf("Failed to align tile pair %v %v: %w", tileA, tileB, err)
}
operations++
log.Printf("(%v/%v)Got alignment for pair %v %v. Offset = %v", operations, maxOperations, tileA, tileB, offset)
// Store tile alignment pair, also reversed.
tp[tileAlignmentKeys{tileA, tileB}] = tileAlignment{offset: offset}
tp[tileAlignmentKeys{tileB, tileA}] = tileAlignment{offset: offset.Mul(-1)}
}
}
}
// Silly and hacky method to determine the minimal error.
// TODO: Use some mixed integer method or something similar to optimize the tile alignment
// The error function returns the x and y error. The axes are optimized independent of each other later on.
errorFunction := func(tiles []*imageTile) (image.Point, error) {
errorValue := image.Point{}
for i, tileA := range tiles {
for j := i + 1; j < len(tiles); j++ {
tileB := tiles[j]
tileAlignment, ok := tp[tileAlignmentKeys{tileA, tileB}]
if !ok {
return image.Point{}, fmt.Errorf("Offset of the tile pair %v %v is missing", tileA, tileB)
}
// The error is the difference between the needed offset, and the actual offsets
tempErrorValue := pointAbs(tileAlignment.offset.Sub(tileA.offset).Add(tileB.offset))
errorValue = errorValue.Add(tempErrorValue)
}
}
return errorValue, nil
}
errorValue, err := errorFunction(tiles)
if err != nil {
return fmt.Errorf("Failed to calculate error value: %w", err)
}
// Randomly select tiles, and move them in the direction where the error value is lower.
// The "gradient" is basically caluclated by try and error.
for i := 0; i < len(tiles)*tileAlignmentSearchRadius*5; i++ {
tile := tiles[rand.Intn(len(tiles))]
// Calculate error value for positive shifting.
tile.offset = tile.offset.Add(image.Point{1, 1})
plusErrorValue, err := errorFunction(tiles)
if err != nil {
return fmt.Errorf("Failed to calculate error value: %w", err)
}
// Calculate error value for negative shifting.
tile.offset = tile.offset.Add(image.Point{-2, -2})
minusErrorValue, err := errorFunction(tiles)
if err != nil {
return fmt.Errorf("Failed to calculate error value: %w", err)
}
// Reset tile movement.
tile.offset = tile.offset.Add(image.Point{1, 1})
// Move this tile towards the smaller error value.
if plusErrorValue.X < errorValue.X {
tile.offset = tile.offset.Add(image.Point{1, 0})
}
if minusErrorValue.X < errorValue.X {
tile.offset = tile.offset.Add(image.Point{-1, 0})
}
if plusErrorValue.Y < errorValue.Y {
tile.offset = tile.offset.Add(image.Point{0, 1})
}
if minusErrorValue.Y < errorValue.Y {
tile.offset = tile.offset.Add(image.Point{0, -1})
}
}
// TODO: Move images in a way that the majority of images is positioned equal to their original position
return nil
}
func (tp tilePairs) Stitch(tiles []imageTile, destImage *image.RGBA) error {
intersectTiles := []*imageTile{}
images := []*image.RGBA{}
// Get only the tiles that intersect with the destination image bounds.
// Ignore alignment here, doesn't matter if an image overlaps a few pixels anyways.
for i, tile := range tiles {
if tile.OffsetBounds().Overlaps(destImage.Bounds()) {
tilePtr := &tiles[i]
intersectTiles = append(intersectTiles, tilePtr)
img, err := tilePtr.GetImage()
if err != nil {
return fmt.Errorf("Couldn't get image: %w", err)
}
imgCopy := *img
imgCopy.Rect = imgCopy.Rect.Add(tile.offset).Inset(4) // Reduce image bounds by 4 pixels on each side, because otherwise there will be artifacts.
images = append(images, &imgCopy)
}
}
//log.Printf("intersectTiles: %v", intersectTiles)
// Align those tiles
/*if err := tp.alignTiles(intersectTiles); err != nil {
return fmt.Errorf("Failed to align tiles: %w", err)
}*/
// TODO: Add working aligning algorithm
/*for _, intersectTile := range intersectTiles {
intersectTile.loadImage()
draw.Draw(destImage, destImage.Bounds(), intersectTile.image, destImage.Bounds().Min, draw.Over)
}*/
/*for _, intersectTile := range intersectTiles {
drawLabel(destImage, intersectTile.image.Bounds().Min.X, intersectTile.image.Bounds().Min.Y, fmt.Sprintf("%v", intersectTile.fileName))
}*/
drawMedianBlended(images, destImage)
return nil
}
// StitchGrid calls stitch, but divides the workload into a grid of chunks.
// Additionally it runs the workload multithreaded.
func (tp tilePairs) StitchGrid(tiles []imageTile, destImage *image.RGBA, gridSize int) (errResult error) {
//workloads := gridifyRectangle(destImage.Bounds(), gridSize)
workloads, err := hilbertifyRectangle(destImage.Bounds(), gridSize)
if err != nil {
return err
}
bar := progressbar.New(len(workloads))
bar.RenderBlank()
// Start worker threads
wc := make(chan image.Rectangle)
wg := sync.WaitGroup{}
for i := 0; i < runtime.NumCPU()*2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for workload := range wc {
if err := tp.Stitch(tiles, destImage.SubImage(workload).(*image.RGBA)); err != nil {
errResult = err // This will not stop execution, but at least one of any errors is returned.
}
bar.Add(1)
}
}()
}
// Push workload to worker threads
for _, workload := range workloads {
wc <- workload
}
// Wait until all worker threads are done
close(wc)
wg.Wait()
// Newline because of the progress bar
fmt.Println("")
return
}
func drawMedianBlended(images []*image.RGBA, destImage *image.RGBA) {
bounds := destImage.Bounds()
// Create arrays to be reused every pixel
rListEmpty, gListEmpty, bListEmpty := make([]int, 0, len(images)), make([]int, 0, len(images)), make([]int, 0, len(images))
for iy := bounds.Min.Y; iy < bounds.Max.Y; iy++ {
for ix := bounds.Min.X; ix < bounds.Max.X; ix++ {
rList, gList, bList := rListEmpty, gListEmpty, bListEmpty
point := image.Point{ix, iy}
found := false
// Iterate through all images and create a list of colors.
for _, img := range images {
if point.In(img.Bounds()) {
col := img.RGBAAt(point.X, point.Y)
rList, gList, bList = append(rList, int(col.R)), append(gList, int(col.G)), append(bList, int(col.B))
found = true
}
}
// If there were no images to get data from, ignore the pixel.
if !found {
continue
}
// Sort colors.
sort.Ints(rList)
sort.Ints(gList)
sort.Ints(bList)
// Take the middle element of each color.
var r, g, b uint8
if len(rList)%2 == 0 {
// Even
r = uint8((rList[len(rList)/2-1] + rList[len(rList)/2]) / 2)
} else {
// Odd
r = uint8(rList[(len(rList)-1)/2])
}
if len(gList)%2 == 0 {
// Even
g = uint8((gList[len(gList)/2-1] + gList[len(gList)/2]) / 2)
} else {
// Odd
g = uint8(gList[(len(gList)-1)/2])
}
if len(bList)%2 == 0 {
// Even
b = uint8((bList[len(bList)/2-1] + bList[len(bList)/2]) / 2)
} else {
// Odd
b = uint8(bList[(len(bList)-1)/2])
}
destImage.SetRGBA(ix, iy, color.RGBA{r, g, b, 255})
}
}
}

356
bin/stitch/main.go Normal file
View File

@ -0,0 +1,356 @@
// Copyright (c) 2019-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"flag"
"fmt"
"image"
"log"
"path/filepath"
"strings"
"time"
"github.com/1lann/promptui"
"github.com/cheggaaa/pb/v3"
)
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`, `.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.")
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.")
var flagYMax = flag.Int("ymax", 0, "Lower bound of the output rectangle. This coordinate is not included in the output.")
func main() {
log.Printf("Noita MapCapture stitching tool v%s.", version)
flag.Parse()
var overlays []StitchedImageOverlay
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter downscaling factor:",
Default: fmt.Sprint(*flagScaleDivider),
AllowEdit: true,
Validate: func(s string) error {
var num int
_, err := fmt.Sscanf(s, "%d", &num)
if err != nil {
return err
}
if int(num) < 1 {
return fmt.Errorf("number must be larger than 0")
}
return nil
},
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
fmt.Sscanf(result, "%d", flagScaleDivider)
}
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter blend tile limit:",
Default: fmt.Sprint(*flagBlendTileLimit),
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("number must be at least 0")
}
return nil
},
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
fmt.Sscanf(result, "%d", flagBlendTileLimit)
}
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter input path:",
Default: *flagInputPath,
AllowEdit: true,
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
*flagInputPath = result
}
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter \"entities.json\" path:",
Default: *flagEntitiesInputPath,
AllowEdit: true,
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
*flagEntitiesInputPath = result
}
// Load entities if requested.
entities, err := LoadEntities(*flagEntitiesInputPath)
if err != nil {
log.Printf("Failed to load entities: %v.", err)
}
if len(entities) > 0 {
log.Printf("Got %v entities.", len(entities))
overlays = append(overlays, entities) // Add entities to overlay drawing list.
}
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter \"player-path.json\" path:",
Default: *flagPlayerPathInputPath,
AllowEdit: true,
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
*flagPlayerPathInputPath = result
}
// Load player path if requested.
playerPath, err := LoadPlayerPath(*flagPlayerPathInputPath)
if err != nil {
log.Printf("Failed to load player path: %v.", err)
}
if len(playerPath) > 0 {
log.Printf("Got %v player path entries.", len(playerPath))
overlays = append(overlays, playerPath) // Add player path to overlay drawing list.
}
log.Printf("Starting to read tile information at %q.", *flagInputPath)
tiles, err := LoadImageTiles(*flagInputPath, *flagScaleDivider)
if err != nil {
log.Panic(err)
}
if len(tiles) == 0 {
log.Panicf("Got no image tiles from %q.", *flagInputPath)
}
log.Printf("Got %v tiles.", len(tiles))
totalBounds := image.Rectangle{}
for i, tile := range tiles {
if i == 0 {
totalBounds = tile.Bounds()
} else {
totalBounds = totalBounds.Union(tile.Bounds())
}
}
log.Printf("Total size of the possible output space is %v.", totalBounds)
// If the output rect is empty, use the rectangle that encloses all tiles.
outputRect := image.Rect(*flagXMin, *flagYMin, *flagXMax, *flagYMax)
if outputRect.Empty() {
outputRect = totalBounds
}
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter output rectangle (xMin,yMin;xMax,yMax):",
Default: fmt.Sprintf("%d,%d;%d,%d", outputRect.Min.X, outputRect.Min.Y, outputRect.Max.X, outputRect.Max.Y),
AllowEdit: true,
Validate: func(s string) error {
var xMin, yMin, xMax, yMax int
_, err := fmt.Sscanf(s, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax)
if err != nil {
return err
}
rect := image.Rect(xMin, yMin, xMax, yMax)
if rect.Empty() {
return fmt.Errorf("rectangle must not be empty")
}
outputRect = rect
return nil
},
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
var xMin, yMin, xMax, yMax int
fmt.Sscanf(result, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax)
outputRect = image.Rect(xMin, yMin, xMax, yMax)
}
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter output filename and path:",
Default: *flagOutputPath,
AllowEdit: true,
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
*flagOutputPath = result
}
fileExtension := strings.ToLower(filepath.Ext(*flagOutputPath))
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 && fileExtension == ".dzi" {
prompt := promptui.Prompt{
Label: "Enter DZI tile size:",
Default: fmt.Sprint(*flagDZITileSize),
AllowEdit: true,
Validate: func(s string) error {
var num int
_, err := fmt.Sscanf(s, "%d", &num)
if err != nil {
return err
}
if int(num) < 1 {
return fmt.Errorf("number must be at least 1")
}
return nil
},
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
fmt.Sscanf(result, "%d", flagDZITileSize)
}
// Query the user, if there were no cmd arguments given.
if flag.NFlag() == 0 && fileExtension == ".dzi" {
prompt := promptui.Prompt{
Label: "Enter DZI tile overlap:",
Default: fmt.Sprint(*flagDZIOverlap),
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("number must be at least 0")
}
return nil
},
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v.", err)
}
fmt.Sscanf(result, "%d", flagDZIOverlap)
}
// 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")
}
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.
}
stitchedImage, err := NewStitchedImage(tiles, outputRect, blendMethod, 128, overlays)
if err != nil {
log.Panicf("NewStitchedImage() failed: %v.", err)
}
bar := pb.Full.New(0)
switch fileExtension {
case ".png":
if err := exportPNGStitchedImage(stitchedImage, *flagOutputPath, bar); err != nil {
log.Panicf("Export of PNG file failed: %v", err)
}
case ".jpg", ".jpeg":
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 := 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)
}
log.Printf("Created output in %v.", time.Since(bar.StartTime()))
//fmt.Println("Press the enter key to terminate the console screen!")
//fmt.Scanln()
}

101
bin/stitch/player-path.go Normal file
View File

@ -0,0 +1,101 @@
// Copyright (c) 2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"encoding/json"
"image"
"image/color"
"math"
"os"
"github.com/tdewolff/canvas"
"github.com/tdewolff/canvas/renderers/rasterizer"
)
var playerPathDisplayStyle = canvas.Style{
Fill: canvas.Paint{},
//Stroke: canvas.Paint{Color: color.RGBA{0, 0, 0, 127}},
StrokeWidth: 3.0,
StrokeCapper: canvas.ButtCap,
StrokeJoiner: canvas.MiterJoin,
DashOffset: 0.0,
Dashes: []float64{},
FillRule: canvas.NonZero,
}
type PlayerPathElement struct {
From [2]float64 `json:"from"`
To [2]float64 `json:"to"`
HP float64 `json:"hp"`
MaxHP float64 `json:"maxHP"`
Polymorphed bool `json:"polymorphed"`
}
type PlayerPath []PlayerPathElement
func LoadPlayerPath(path string) (PlayerPath, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
var result PlayerPath
jsonDec := json.NewDecoder(file)
if err := jsonDec.Decode(&result); err != nil {
return nil, err
}
return result, nil
}
// Draw implements the StitchedImageOverlay interface.
func (p PlayerPath) Draw(destImage *image.RGBA) {
destRect := destImage.Bounds()
// Same as destImage, but top left is translated to (0, 0).
originImage := destImage.SubImage(destRect).(*image.RGBA)
originImage.Rect = originImage.Rect.Sub(destRect.Min)
c := canvas.New(float64(destRect.Dx()), float64(destRect.Dy()))
ctx := canvas.NewContext(c)
ctx.SetCoordSystem(canvas.CartesianIV)
ctx.SetCoordRect(canvas.Rect{X: -float64(destRect.Min.X), Y: -float64(destRect.Min.Y), W: float64(destRect.Dx()), H: float64(destRect.Dy())}, float64(destRect.Dx()), float64(destRect.Dy()))
// Set drawing style.
ctx.Style = playerPathDisplayStyle
for _, pathElement := range p {
from, to := pathElement.From, pathElement.To
// Only draw if the path may cross the image rectangle.
pathRect := image.Rectangle{image.Point{int(from[0]), int(from[1])}, image.Point{int(to[0]), int(to[1])}}.Canon().Inset(int(-playerPathDisplayStyle.StrokeWidth) - 1)
if pathRect.Overlaps(destRect) {
path := &canvas.Path{}
path.MoveTo(from[0], from[1])
path.LineTo(to[0], to[1])
if pathElement.Polymorphed {
// Set stroke color to typically polymorph color.
ctx.Style.Stroke.Color = color.RGBA{127, 50, 83, 127}
} else {
// Set stroke color depending on HP level.
hpFactor := math.Max(math.Min(pathElement.HP/pathElement.MaxHP, 1), 0)
hpFactorInv := 1 - hpFactor
r, g, b, a := uint8((0*hpFactor+1*hpFactorInv)*127), uint8((1*hpFactor+0*hpFactorInv)*127), uint8(0), uint8(127)
ctx.Style.Stroke.Color = color.RGBA{r, g, b, a}
}
ctx.DrawPath(0, 0, path)
}
}
// Theoretically we would need to linearize imgRGBA first, but DefaultColorSpace assumes that the color space is linear already.
r := rasterizer.FromImage(originImage, canvas.DPMM(1.0), canvas.DefaultColorSpace)
c.RenderTo(r)
r.Close() // This just transforms the image's luminance curve back from linear into non linear.
}

21
bin/stitch/profiling.go Normal file
View File

@ -0,0 +1,21 @@
// Copyright (c) 2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
_ "net/http/pprof"
)
func init() {
/*port := 1234
go func() {
http.ListenAndServe(fmt.Sprintf(":%d", port), nil)
}()
log.Printf("Profiler web server listening on port %d. Visit http://localhost:%d/debug/pprof", port, port)
log.Printf("To profile the next 10 seconds and view the profile interactively:\n go tool pprof -http :8080 http://localhost:%d/debug/pprof/profile?seconds=10", port)
*/
}

View File

@ -1,181 +0,0 @@
// Copyright (c) 2019 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"flag"
"fmt"
"image"
"image/png"
"log"
"os"
"path/filepath"
"github.com/manifoldco/promptui"
)
var flagInputPath = flag.String("input", filepath.Join(".", "..", "..", "output"), "The source path of the image tiles to be stitched.")
var flagOutputPath = flag.String("output", filepath.Join(".", "output.png"), "The path and filename of the resulting stitched image.")
var flagScaleDivider = flag.Int("divide", 2, "A downscaling factor. 2 will produce an image with half the side lengths.")
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.")
var flagYMax = flag.Int("ymax", 0, "Lower bound of the output rectangle. This coordinate is not included in the output.")
func main() {
flag.Parse()
// Query the user, if there were no cmd arguments given
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter downscaling factor:",
Default: fmt.Sprint(*flagScaleDivider),
AllowEdit: true,
Validate: func(s string) error {
var num int
_, err := fmt.Sscanf(s, "%d", &num)
if err != nil {
return err
}
if int(num) < 1 {
return fmt.Errorf("Number must be larger than 0")
}
return nil
},
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v", err)
}
fmt.Sscanf(result, "%d", flagScaleDivider)
}
// Query the user, if there were no cmd arguments given
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter input path:",
Default: *flagInputPath,
AllowEdit: true,
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v", err)
}
*flagInputPath = result
}
log.Printf("Starting to read tile information at \"%v\"", *flagInputPath)
tiles, err := loadImages(*flagInputPath, *flagScaleDivider)
if err != nil {
log.Panic(err)
}
if len(tiles) == 0 {
log.Panicf("Got no tiles inside of %v", *flagInputPath)
}
log.Printf("Got %v tiles", len(tiles))
totalBounds := image.Rectangle{}
for i, tile := range tiles {
if i == 0 {
totalBounds = tile.Bounds()
} else {
totalBounds = totalBounds.Union(tile.Bounds())
}
}
log.Printf("Total size of the possible output space is %v", totalBounds)
/*profFile, err := os.Create("cpu.prof")
if err != nil {
log.Panicf("could not create CPU profile: %v", err)
}
defer profFile.Close()
if err := pprof.StartCPUProfile(profFile); err != nil {
log.Panicf("could not start CPU profile: %v", err)
}
defer pprof.StopCPUProfile()*/
// If the output rect is empty, use the rectangle that encloses all tiles
outputRect := image.Rect(*flagXMin, *flagYMin, *flagXMax, *flagYMax)
if outputRect.Empty() {
outputRect = totalBounds
}
// Query the user, if there were no cmd arguments given
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter output rectangle (xMin,yMin;xMax,yMax):",
Default: fmt.Sprintf("%d,%d;%d,%d", outputRect.Min.X, outputRect.Min.Y, outputRect.Max.X, outputRect.Max.Y),
AllowEdit: true,
Validate: func(s string) error {
var xMin, yMin, xMax, yMax int
_, err := fmt.Sscanf(s, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax)
if err != nil {
return err
}
rect := image.Rect(xMin, yMin, xMax, yMax)
if rect.Empty() {
return fmt.Errorf("Rectangle must not be empty")
}
outputRect = rect
return nil
},
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v", err)
}
var xMin, yMin, xMax, yMax int
fmt.Sscanf(result, "%d,%d;%d,%d", &xMin, &yMin, &xMax, &yMax)
outputRect = image.Rect(xMin, yMin, xMax, yMax)
}
// Query the user, if there were no cmd arguments given
if flag.NFlag() == 0 {
prompt := promptui.Prompt{
Label: "Enter output filename and path:",
Default: *flagOutputPath,
AllowEdit: true,
}
result, err := prompt.Run()
if err != nil {
log.Panicf("Error while getting user input: %v", err)
}
*flagOutputPath = result
}
log.Printf("Creating output image with a size of %v", outputRect.Size())
outputImage := image.NewRGBA(outputRect)
log.Printf("Stitching %v tiles into an image at %v", len(tiles), outputImage.Bounds())
tp := make(tilePairs)
if err := tp.StitchGrid(tiles, outputImage, 512); err != nil {
log.Panic(err)
}
log.Printf("Creating output file \"%v\"", "output.png")
f, err := os.Create("output.png")
if err != nil {
log.Panic(err)
}
if err := png.Encode(f, outputImage); err != nil {
f.Close()
log.Panic(err)
}
if err := f.Close(); err != nil {
log.Panic(err)
}
log.Printf("Created output file \"%v\"", "output.png")
}

View File

@ -0,0 +1,156 @@
// Copyright (c) 2022-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"image"
"image/color"
"image/draw"
"runtime"
"sync"
)
// StitchedImageCache contains part of the actual image data of a stitched image.
// This can be regenerated or invalidated at will.
type StitchedImageCache struct {
sync.Mutex
stitchedImage *StitchedImage // The parent object.
rect image.Rectangle // Position and size of the cached area.
image *image.RGBA // Cached RGBA image. The bounds of this image are determined by the filename.
idleCounter byte // Is incremented when this cache object idles, and reset every time the cache is used.
}
func NewStitchedImageCache(stitchedImage *StitchedImage, rect image.Rectangle) StitchedImageCache {
return StitchedImageCache{
stitchedImage: stitchedImage,
rect: rect,
}
}
// InvalidateAuto invalidates this cache object when it had idled for too long.
// The cache will be invalidated after `threshold + 1` calls to InvalidateAuto.
func (sic *StitchedImageCache) InvalidateAuto(threshold byte) {
sic.Lock()
defer sic.Unlock()
if sic.image != nil {
if sic.idleCounter >= threshold {
sic.image = nil
return
}
sic.idleCounter++
}
}
// Invalidate clears the cached image.
func (sic *StitchedImageCache) Invalidate() {
sic.Lock()
defer sic.Unlock()
sic.image = nil
}
// Regenerate refills the cache image with valid image data.
// This will block until there is a valid image, and it will *always* return a valid image.
func (sic *StitchedImageCache) Regenerate() *image.RGBA {
sic.Lock()
defer sic.Unlock()
sic.idleCounter = 0
// Check if there is already a cache image.
if sic.image != nil {
return sic.image
}
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{}
for i := range si.tiles {
tile := &si.tiles[i]
if tile.Bounds().Overlaps(sic.rect) {
intersectingTiles = append(intersectingTiles, tile)
}
}
// Start worker threads.
workerQueue := make(chan image.Rectangle)
waitGroup := sync.WaitGroup{}
workers := (runtime.NumCPU() + 1) / 2
for i := 0; i < workers; i++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
for workload := range workerQueue {
// List of tiles that intersect with the workload chunk.
workloadTiles := []*ImageTile{}
// Get only the tiles that intersect with the workload bounds.
for _, tile := range intersectingTiles {
if tile.Bounds().Overlaps(workload) {
workloadTiles = append(workloadTiles, tile)
}
}
// Draw blended tiles into cache image.
// Restricted by the workload rectangle.
si.blendMethod.Draw(workloadTiles, cacheImage.SubImage(workload).(*image.RGBA))
}
}()
}
// Divide rect into chunks and push to workers.
for _, chunk := range GridifyRectangle(sic.rect, StitchedImageCacheGridSize) {
workerQueue <- chunk
}
close(workerQueue)
// Wait until all worker threads are done.
waitGroup.Wait()
// Draw overlays.
for _, overlay := range si.overlays {
if overlay != nil {
overlay.Draw(cacheImage)
}
}
// Update cached image.
sic.image = cacheImage
return cacheImage
}
// Returns the pixel color at x and y.
func (sic *StitchedImageCache) RGBAAt(x, y int) color.RGBA {
// Fast path: The image is loaded.
sic.Lock()
if sic.image != nil {
defer sic.Unlock()
sic.idleCounter = 0
return sic.image.RGBAAt(x, y)
}
sic.Unlock()
// Slow path: The image data needs to be generated first.
// This will block until the cache is regenerated.
return sic.Regenerate().RGBAAt(x, y)
}
// Returns the pixel color at x and y.
func (sic *StitchedImageCache) At(x, y int) color.Color {
return sic.RGBAAt(x, y)
}
func (sic *StitchedImageCache) Bounds() image.Rectangle {
return sic.rect
}

View File

@ -0,0 +1,166 @@
// Copyright (c) 2022-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"fmt"
"image"
"image/color"
"sync/atomic"
"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
// StitchedImageBlendMethod defines how tiles are blended together.
type StitchedImageBlendMethod interface {
Draw(tiles []*ImageTile, destImage *image.RGBA) // Draw is called when a new cache image is generated.
}
// StitchedImageOverlay defines an interface for arbitrary overlays that can be drawn over the stitched image.
type StitchedImageOverlay interface {
Draw(*image.RGBA)
}
// StitchedImage combines several ImageTile objects into a single RGBA image.
// The way the images are combined/blended is defined by the blendFunc.
type StitchedImage struct {
tiles ImageTiles
bounds image.Rectangle
blendMethod StitchedImageBlendMethod
overlays []StitchedImageOverlay
cacheRowHeight int
cacheRows []StitchedImageCache
cacheRowYOffset int // Defines the pixel offset of the first cache row.
oldCacheRowIndex int
queryCounter atomic.Int64
}
// NewStitchedImage creates a new image from several single image tiles.
func NewStitchedImage(tiles ImageTiles, bounds image.Rectangle, blendMethod StitchedImageBlendMethod, cacheRowHeight int, overlays []StitchedImageOverlay) (*StitchedImage, error) {
if bounds.Empty() {
return nil, fmt.Errorf("given boundaries are empty")
}
if blendMethod == nil {
return nil, fmt.Errorf("no blending method given")
}
if cacheRowHeight <= 0 {
return nil, fmt.Errorf("invalid cache row height of %d pixels", cacheRowHeight)
}
stitchedImage := &StitchedImage{
tiles: tiles,
bounds: bounds,
blendMethod: blendMethod,
overlays: overlays,
}
// Generate cache image rows.
maxRow := (bounds.Dy() - 1) / cacheRowHeight
var cacheRows []StitchedImageCache
for i := 0; i <= maxRow; i++ {
rect := image.Rect(bounds.Min.X, bounds.Min.Y+i*cacheRowHeight, bounds.Max.X, bounds.Min.Y+(i+1)*cacheRowHeight)
cacheRows = append(cacheRows, NewStitchedImageCache(stitchedImage, rect.Intersect(bounds)))
}
stitchedImage.cacheRowHeight = cacheRowHeight
stitchedImage.cacheRowYOffset = -bounds.Min.Y
stitchedImage.cacheRows = cacheRows
// Start ticker to automatically invalidate caches.
// Due to this, the stitchedImage object is not composable, as this goroutine will always have a reference.
go func() {
ticker := time.NewTicker(1 * time.Second)
for range ticker.C {
for rowIndex := range stitchedImage.cacheRows {
stitchedImage.cacheRows[rowIndex].InvalidateAuto(3) // Invalidate cache row after 3 seconds of being idle.
}
}
}()
return stitchedImage, nil
}
// ColorModel returns the Image's color model.
func (si *StitchedImage) ColorModel() color.Model {
return color.RGBAModel
}
// Bounds returns the domain for which At can return non-zero color.
// The bounds do not necessarily contain the point (0, 0).
func (si *StitchedImage) Bounds() image.Rectangle {
return si.bounds
}
func (si *StitchedImage) At(x, y int) color.Color {
return si.RGBAAt(x, y)
}
// At returns the color of the pixel at (x, y).
//
// This is optimized to be read line by line (scanning), it will be much slower with random access.
//
// For the `Progress()` method to work correctly, every pixel should be queried exactly once.
//
// At(Bounds().Min.X, Bounds().Min.Y) // returns the top-left pixel of the image.
// At(Bounds().Max.X-1, Bounds().Max.Y-1) // returns the bottom-right pixel.
func (si *StitchedImage) RGBAAt(x, y int) color.RGBA {
// Assume that every pixel is only queried once.
si.queryCounter.Add(1)
// Determine the cache rowIndex index.
rowIndex := (y + si.cacheRowYOffset) / si.cacheRowHeight
if rowIndex < 0 || rowIndex >= len(si.cacheRows) {
return colorBackground
}
// Check if we advanced/changed the row index.
// This doesn't happen a lot, so stuff inside this can be a bit more expensive.
if si.oldCacheRowIndex != rowIndex {
// Pre generate the new row asynchronously.
newRowIndex := rowIndex + 1
if newRowIndex >= 0 && newRowIndex < len(si.cacheRows) {
go si.cacheRows[newRowIndex].Regenerate()
}
// Invalidate all tiles that are above the next row.
si.tiles.InvalidateAboveY((rowIndex+1)*si.cacheRowHeight - si.cacheRowYOffset)
si.oldCacheRowIndex = rowIndex
}
return si.cacheRows[rowIndex].RGBAAt(x, y)
}
// Opaque returns whether the image is fully opaque.
//
// For more speed and smaller file size, StitchedImage will be marked as non-transparent.
// This will speed up image saving by 2x, as there is no need to iterate over the whole image just to find a single non opaque pixel.
func (si *StitchedImage) Opaque() bool {
return true
}
// Progress returns the approximate progress of any process that scans the image from top to bottom.
func (si *StitchedImage) Progress() (value, max int) {
size := si.Bounds().Size()
return int(si.queryCounter.Load()), size.X * size.Y
}
// SubStitchedImage returns an image representing the portion of the image p visible through r.
// The returned image references to the original stitched image, and therefore reuses its cache.
func (si *StitchedImage) SubStitchedImage(r image.Rectangle) SubStitchedImage {
return SubStitchedImage{
StitchedImage: si,
bounds: si.Bounds().Intersect(r),
}
}

View File

@ -0,0 +1,36 @@
// Copyright (c) 2023-2024 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"image"
"image/color"
)
type SubStitchedImage struct {
*StitchedImage // The original stitched image.
bounds image.Rectangle // The new bounds of the cropped image.
}
// Bounds returns the domain for which At can return non-zero color.
// The bounds do not necessarily contain the point (0, 0).
func (s SubStitchedImage) Bounds() image.Rectangle {
return s.bounds
}
func (s SubStitchedImage) At(x, y int) color.Color {
return s.RGBAAt(x, y)
}
func (s SubStitchedImage) RGBAAt(x, y int) color.RGBA {
point := image.Point{X: x, Y: y}
if !point.In(s.bounds) {
return colorBackground
}
return s.StitchedImage.RGBAAt(x, y)
}

View File

@ -1,4 +1,4 @@
// Copyright (c) 2019 David Vogel // Copyright (c) 2019-2024 David Vogel
// //
// This software is released under the MIT License. // This software is released under the MIT License.
// https://opensource.org/licenses/MIT // https://opensource.org/licenses/MIT
@ -8,69 +8,67 @@ package main
import ( import (
"fmt" "fmt"
"image" "image"
"image/color"
"math"
"os" "os"
"sort" "sync"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
"github.com/google/hilbert"
) )
// QuickSelect returns the kth smallest element of the given unsorted list.
// This is faster than sorting the list and then selecting the wanted element.
//
// Source: https://rosettacode.org/wiki/Quickselect_algorithm#Go
func QuickSelectUInt8(list []uint8, k int) uint8 {
for {
// Partition.
px := len(list) / 2
pv := list[px]
last := len(list) - 1
list[px], list[last] = list[last], list[px]
i := 0
for j := 0; j < last; j++ {
if list[j] < pv {
list[i], list[j] = list[j], list[i]
i++
}
}
// Select.
if i == k {
return pv
}
if k < i {
list = list[:i]
} else {
list[i], list[last] = list[last], list[i]
list = list[i+1:]
k -= i + 1
}
}
}
// Source: https://gist.github.com/sergiotapia/7882944 // Source: https://gist.github.com/sergiotapia/7882944
func getImageFileDimension(imagePath string) (int, int, error) { func GetImageFileDimension(imagePath string) (int, int, error) {
file, err := os.Open(imagePath) file, err := os.Open(imagePath)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("Can't open file %v: %w", imagePath, err) return 0, 0, fmt.Errorf("can't open file %v: %w", imagePath, err)
} }
defer file.Close() defer file.Close()
image, _, err := image.DecodeConfig(file) image, _, err := image.DecodeConfig(file)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("Error decoding config of image file %v: %w", imagePath, err) return 0, 0, fmt.Errorf("error decoding config of image file %v: %w", imagePath, err)
} }
return image.Width, image.Height, nil return image.Width, image.Height, nil
} }
// getImageDifferenceValue returns the average quadratic difference of the (sub)pixels. func GridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectangle) {
// 0 means the images are identical, +inf means that the images don't intersect. for y := DivideFloor(rect.Min.Y, gridSize); y <= DivideCeil(rect.Max.Y-1, gridSize); y++ {
func getImageDifferenceValue(a, b *image.RGBA, offsetA image.Point) float64 { for x := DivideFloor(rect.Min.X, gridSize); x <= DivideCeil(rect.Max.X-1, gridSize); x++ {
intersection := a.Bounds().Add(offsetA).Intersect(b.Bounds())
if intersection.Empty() {
return math.Inf(1)
}
aSub := a.SubImage(intersection.Sub(offsetA)).(*image.RGBA)
bSub := b.SubImage(intersection).(*image.RGBA)
intersectionWidth := intersection.Dx() * 4
intersectionHeight := intersection.Dy()
var value int64
for iy := 0; iy < intersectionHeight; iy++ {
aSlice := aSub.Pix[iy*aSub.Stride : iy*aSub.Stride+intersectionWidth]
bSlice := bSub.Pix[iy*bSub.Stride : iy*bSub.Stride+intersectionWidth]
for ix := 0; ix < intersectionWidth; ix += 3 {
diff := int64(aSlice[ix]) - int64(bSlice[ix])
value += diff * diff
}
}
return float64(value) / float64(intersectionWidth*intersectionHeight)
}
func gridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectangle) {
for y := divideFloor(rect.Min.Y, gridSize); y < divideCeil(rect.Max.Y, gridSize); y++ {
for x := divideFloor(rect.Min.X, gridSize); x < divideCeil(rect.Max.X, gridSize); x++ {
tempRect := image.Rect(x*gridSize, y*gridSize, (x+1)*gridSize, (y+1)*gridSize) tempRect := image.Rect(x*gridSize, y*gridSize, (x+1)*gridSize, (y+1)*gridSize)
if tempRect.Overlaps(rect) { intersection := tempRect.Intersect(rect)
result = append(result, tempRect) if !intersection.Empty() {
result = append(result, intersection)
} }
} }
} }
@ -78,63 +76,8 @@ func gridifyRectangle(rect image.Rectangle, gridSize int) (result []image.Rectan
return return
} }
func hilbertifyRectangle(rect image.Rectangle, gridSize int) ([]image.Rectangle, error) {
grid := gridifyRectangle(rect, gridSize)
gridX := divideFloor(rect.Min.X, gridSize)
gridY := divideFloor(rect.Min.Y, gridSize)
// Size of the grid in chunks
gridWidth := divideCeil(rect.Max.X, gridSize) - divideFloor(rect.Min.X, gridSize)
gridHeight := divideCeil(rect.Max.Y, gridSize) - divideFloor(rect.Min.Y, gridSize)
s, err := hilbert.NewHilbert(int(math.Pow(2, math.Ceil(math.Log2(math.Max(float64(gridWidth), float64(gridHeight)))))))
if err != nil {
return nil, err
}
sort.Slice(grid, func(i, j int) bool {
// Ignore out of range errors, as they shouldn't happen.
hilbertIndexA, _ := s.MapInverse(grid[i].Min.X/gridSize-gridX, grid[i].Min.Y/gridSize-gridY)
hilbertIndexB, _ := s.MapInverse(grid[j].Min.X/gridSize-gridX, grid[j].Min.Y/gridSize-gridY)
return hilbertIndexA < hilbertIndexB
})
return grid, nil
}
func drawLabel(img *image.RGBA, x, y int, label string) {
col := color.RGBA{200, 100, 0, 255}
point := fixed.Point26_6{X: fixed.Int26_6(x * 64), Y: fixed.Int26_6(y * 64)}
d := &font.Drawer{
Dst: img,
Src: image.NewUniform(col),
Face: basicfont.Face7x13,
Dot: point,
}
d.DrawString(label)
}
func intAbs(x int) int {
if x < 0 {
return -x
}
return x
}
func pointAbs(p image.Point) image.Point {
if p.X < 0 {
p.X = -p.X
}
if p.Y < 0 {
p.Y = -p.Y
}
return p
}
// Integer division that rounds to the next integer towards negative infinity. // Integer division that rounds to the next integer towards negative infinity.
func divideFloor(a, b int) int { func DivideFloor(a, b int) int {
temp := a / b temp := a / b
if ((a ^ b) < 0) && (a%b != 0) { if ((a ^ b) < 0) && (a%b != 0) {
@ -145,7 +88,7 @@ func divideFloor(a, b int) int {
} }
// Integer division that rounds to the next integer towards positive infinity. // Integer division that rounds to the next integer towards positive infinity.
func divideCeil(a, b int) int { func DivideCeil(a, b int) int {
temp := a / b temp := a / b
if ((a ^ b) >= 0) && (a%b != 0) { if ((a ^ b) >= 0) && (a%b != 0) {
@ -155,9 +98,43 @@ func divideCeil(a, b int) int {
return temp return temp
} }
func maxInt(x, y int) int { // https://gist.github.com/cstockton/d611ced26bb6b4d3f7d4237abb8613c4
if x > y { type LimitGroup struct {
return x wg sync.WaitGroup
} mu *sync.Mutex
return y 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() }

29
bin/stitch/version.go Normal file
View File

@ -0,0 +1,29 @@
// Copyright (c) 2022 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
package main
import (
"strings"
"github.com/coreos/go-semver/semver"
)
// versionString contains the semantic version of the software as a string.
//
// This variable is only used to transfer the correct version information into the build.
// Don't use this variable in the software, use `version` instead.
//
// When building the software, the default `v0.0.0-development` is used.
// To compile the program with the correct version information, compile the following way:
//
// `go build -ldflags="-X 'main.versionString=x.y.z'"`, where `x.y.z` is the correct and valid version from the git tag.
// This variable may or may not contain the prefix v.
var versionString = "0.0.0-development"
// version of the program.
//
// When converted into a string, this will not contain the v prefix.
var version = semver.Must(semver.NewVersion(strings.TrimPrefix(versionString, "v")))

3
compatibility.xml Normal file
View File

@ -0,0 +1,3 @@
<Mod _format_version="0"
version_built_with="4">
</Mod>

View File

@ -1,5 +0,0 @@
// Copyright (c) 2019 David Vogel
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT

View File

@ -0,0 +1,70 @@
<Entity name="card" tags="card_action">
<ItemComponent
_tags="enabled_in_world"
play_spinning_animation="0"
preferred_inventory="FULL"
></ItemComponent>
<HitboxComponent
_tags="enabled_in_world"
aabb_min_x="-4"
aabb_max_x="4"
aabb_min_y="-3"
aabb_max_y="3"
></HitboxComponent>
<SimplePhysicsComponent
_tags="enabled_in_world"
></SimplePhysicsComponent>
<VelocityComponent
_tags="enabled_in_world"
></VelocityComponent>
<!-- <SpriteComponent
_tags="enabled_in_world,item_unlocked"
image_file="data/ui_gfx/gun_actions/empty.png"
offset_x="8"
offset_y="8"
></SpriteComponent>
<SpriteComponent
_tags="enabled_in_world,item_locked"
image_file="data/ui_gfx/gun_actions/unidentified.png"
offset_x="8"
offset_y="8"
></SpriteComponent> -->
<SpriteComponent
_tags="enabled_in_world,item_identified"
image_file="data/ui_gfx/gun_actions/empty.png"
offset_x="8"
offset_y="17"
z_index="-1.51"
></SpriteComponent>
<SpriteComponent
_enabled="0"
_tags="enabled_in_world,item_unidentified"
image_file="data/ui_gfx/gun_actions/unidentified.png"
offset_x="8"
offset_y="17"
z_index="-1.51"
></SpriteComponent>
<SpriteComponent
_enabled="0"
_tags="enabled_in_world,item_bg"
image_file="data/ui_gfx/inventory/item_bg_projectile.png"
offset_x="10"
offset_y="19"
z_index="-1.5"
></SpriteComponent>
<ItemActionComponent
_tags="enabled_in_world"
action_id=""
></ItemActionComponent>
</Entity>

View File

@ -0,0 +1,70 @@
<Entity tags="card_action">
<ItemComponent
_tags="enabled_in_world"
play_spinning_animation="0"
preferred_inventory="FULL"
></ItemComponent>
<HitboxComponent
_tags="enabled_in_world"
aabb_min_x="-4"
aabb_max_x="4"
aabb_min_y="-3"
aabb_max_y="3"
></HitboxComponent>
<SimplePhysicsComponent
_tags="enabled_in_world"
></SimplePhysicsComponent>
<VelocityComponent
_tags="enabled_in_world"
></VelocityComponent>
<!-- <SpriteComponent
_tags="enabled_in_world,item_unlocked"
image_file="data/ui_gfx/gun_actions/unidentified.png"
offset_x="8"
offset_y="8"
></SpriteComponent>
<SpriteComponent
_tags="enabled_in_world,item_locked"
image_file="data/ui_gfx/gun_actions/unidentified.png"
offset_x="8"
offset_y="8"
></SpriteComponent> -->
<SpriteComponent
_tags="enabled_in_world,item_identified"
image_file="data/ui_gfx/gun_actions/empty.png"
offset_x="8"
offset_y="17"
z_index="-1.51"
></SpriteComponent>
<SpriteComponent
_enabled="0"
_tags="enabled_in_world,item_unidentified"
image_file="data/ui_gfx/gun_actions/unidentified.png"
offset_x="8"
offset_y="17"
z_index="-1.51"
></SpriteComponent>
<SpriteComponent
_enabled="0"
_tags="enabled_in_world,item_bg"
image_file="data/ui_gfx/inventory/item_bg_projectile.png"
offset_x="10"
offset_y="19"
z_index="-1.5"
></SpriteComponent>
<ItemActionComponent
_tags="enabled_in_world"
action_id=""
></ItemActionComponent>
</Entity>

View File

@ -0,0 +1,153 @@
<Entity tags="card_action,energy_shield">
<ItemComponent
_tags="enabled_in_world"
play_spinning_animation="0"
preferred_inventory="FULL"
></ItemComponent>
<HitboxComponent
_tags="enabled_in_world"
aabb_min_x="-4"
aabb_max_x="4"
aabb_min_y="-3"
aabb_max_y="3"
></HitboxComponent>
<SimplePhysicsComponent
_tags="enabled_in_world"
></SimplePhysicsComponent>
<VelocityComponent
_tags="enabled_in_world"
></VelocityComponent>
<SpriteComponent
_tags="enabled_in_world,item_identified"
image_file="data/ui_gfx/gun_actions/energy_shield.png"
offset_x="8"
offset_y="17"
z_index="-1.51" >
</SpriteComponent>
<SpriteComponent
_enabled="0"
_tags="enabled_in_world,item_unidentified"
image_file="data/ui_gfx/gun_actions/unidentified.png"
offset_x="8"
offset_y="17"
z_index="-1.51" >
</SpriteComponent>
<SpriteComponent
_enabled="0"
_tags="enabled_in_world,item_bg"
image_file="data/ui_gfx/inventory/item_bg_projectile.png"
offset_x="10"
offset_y="19"
z_index="-1.5"
></SpriteComponent>
<ItemActionComponent
_tags="enabled_in_world"
action_id="ENERGY_SHIELD" >
</ItemActionComponent>
<InheritTransformComponent
_tags="enabled_in_hand"
use_root_parent="1">
<Transform
position.x="0"
position.y="-4" >
</Transform>
</InheritTransformComponent>
<EnergyShieldComponent
_tags="enabled_in_hand,item_identified__LEGACY"
recharge_speed="0.25"
radius="16.0"
>
</EnergyShieldComponent>
<ParticleEmitterComponent
_tags="character,enabled_in_hand,item_identified__LEGACY"
emitted_material_name="plasma_fading"
gravity.y="0.0"
lifetime_min="0.1"
lifetime_max="0.5"
count_min="2"
count_max="4"
render_on_grid="1"
fade_based_on_lifetime="1"
area_circle_radius.max="16"
cosmetic_force_create="0"
airflow_force="0.5"
airflow_time="0.1"
airflow_scale="0.5"
emission_interval_min_frames="1"
emission_interval_max_frames="1"
emit_cosmetic_particles="1"
is_emitting="1" >
</ParticleEmitterComponent>
<ParticleEmitterComponent
_tags="character,enabled_in_hand,item_identified__LEGACY,shield_ring"
emitted_material_name="plasma_fading"
gravity.y="0.0"
lifetime_min="0.02"
lifetime_max="0.05"
count_min="90"
count_max="100"
render_on_grid="1"
fade_based_on_lifetime="1"
area_circle_radius.min="16"
area_circle_radius.max="16"
cosmetic_force_create="0"
airflow_force="0.3"
airflow_time="0.01"
airflow_scale="0.05"
emission_interval_min_frames="0"
emission_interval_max_frames="0"
emit_cosmetic_particles="1"
is_emitting="1" >
</ParticleEmitterComponent>
<ParticleEmitterComponent
_tags="character,enabled_in_hand,item_identified__LEGACY,shield_hit"
emitted_material_name="plasma_fading"
gravity.y="0.0"
lifetime_min="0.3"
lifetime_max="1"
count_min="300"
count_max="360"
render_on_grid="1"
fade_based_on_lifetime="1"
area_circle_radius.min="16"
area_circle_radius.max="16"
cosmetic_force_create="0"
airflow_force="2.8"
airflow_time="0.03"
airflow_scale="0.8"
emission_interval_min_frames="0"
emission_interval_max_frames="0"
emit_cosmetic_particles="1"
is_emitting="0" >
</ParticleEmitterComponent>
<LightComponent
_tags="enabled_in_hand,item_identified"
_enabled="1"
radius="80"
fade_out_time="1.5"
r="150"
g="190"
b="230" >
</LightComponent>
<AudioComponent
_tags="enabled_in_hand,item_identified"
file="data/audio/Desktop/projectiles.bank"
event_root="player_projectiles/shield"
set_latest_event_position="1" >
</AudioComponent>
</Entity>

View File

@ -1,98 +1,896 @@
-- Copyright (c) 2019 David Vogel -- Copyright (c) 2019-2024 David Vogel
-- --
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
local CAPTURE_PIXEL_SIZE = 2 -- in FullHD an ingame pixel is expected to be 2 real pixels --------------------------
local CAPTURE_GRID_SIZE = 256 -- in ingame pixels. There will be 6 to 12 images overlapping -- Load library modules --
local CAPTURE_DELAY = 15 -- in frames --------------------------
local CAPTURE_FORCE_HP = 4 -- * 25HP
local function preparePlayer() local CameraAPI = require("noita-api.camera")
local playerEntity = getPlayer() local Coords = require("coordinates")
addEffectToEntity(playerEntity, "PROTECTION_ALL") local EntityAPI = require("noita-api.entity")
local Hilbert = require("hilbert-curve")
local JSON = require("noita-api.json")
local MonitorStandby = require("monitor-standby")
local ProcessRunner = require("process-runner")
local ScreenCapture = require("screen-capture")
local Utils = require("noita-api.utils")
local Vec2 = require("noita-api.vec2")
addPerkToPlayer("BREATH_UNDERWATER") ------------------
addPerkToPlayer("INVISIBILITY") -- Global stuff --
addPerkToPlayer("REMOVE_FOG_OF_WAR") ------------------
addPerkToPlayer("REPELLING_CAPE")
addPerkToPlayer("WORM_DETRACTOR")
setPlayerHP(CAPTURE_FORCE_HP) ----------
-- Code --
----------
Capture.MapCapturingCtx = Capture.MapCapturingCtx or ProcessRunner.New()
Capture.EntityCapturingCtx = Capture.EntityCapturingCtx or ProcessRunner.New()
Capture.PlayerPathCapturingCtx = Capture.PlayerPathCapturingCtx or ProcessRunner.New()
---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? -- 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
---@return Vec2 bottomRightWorld
local function calculateCaptureRectangle(pos)
local topLeft, bottomRight = Coords:ValidRenderingRect()
-- Convert valid rendering rectangle into world coordinates, and round it towards the window center.
local topLeftWorld, bottomRightWorld = Coords:ToWorld(topLeft, pos):Rounded("ceil"), Coords:ToWorld(bottomRight, pos):Rounded("floor")
-- Convert back into window coordinates, and round to nearest.
local topLeftCapture, bottomRightCapture = Coords:ToWindow(topLeftWorld, pos):Rounded(), Coords:ToWindow(bottomRightWorld, pos):Rounded()
return topLeftCapture, bottomRightCapture, topLeftWorld, bottomRightWorld
end end
local function resetPlayer() ---Captures a screenshot at the given position in world coordinates.
setPlayerHP(CAPTURE_FORCE_HP) ---This will block until all chunks in the virtual rectangle are loaded.
end ---
---Don't set `ensureLoaded` to true when `pos` is nil!
function startCapturing() ---@param pos Vec2? -- Position of the viewport center in world coordinates. If set to nil, the viewport will not be modified.
local ox, oy = GameGetCameraPos() ---@param ensureLoaded boolean? -- If true, the function will wait until all chunks in the virtual rectangle are loaded.
--getPlayerPos() ---@param dontOverwrite boolean? -- If true, the function will abort if there is already a file with the same coordinates.
ox, oy = math.floor(ox / CAPTURE_GRID_SIZE) * CAPTURE_GRID_SIZE, math.floor(oy / CAPTURE_GRID_SIZE) * CAPTURE_GRID_SIZE ---@param ctx ProcessRunnerCtx? -- The process runner context this runs in.
local x, y = ox, oy ---@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.
preparePlayer() local function captureScreenshot(pos, ensureLoaded, dontOverwrite, ctx, outputPixelScale, captureDelay)
if outputPixelScale == 0 or outputPixelScale == nil then
GameSetCameraFree(true) outputPixelScale = Coords:PixelScale()
-- Coroutine to calculate next coordinate, and trigger screenshots
local i = 1
async_loop(
function()
-- +x
for i = 1, i, 1 do
local rx, ry = x * CAPTURE_PIXEL_SIZE, y * CAPTURE_PIXEL_SIZE
if not fileExists(string.format("mods/noita-mapcap/output/%d,%d.png", rx, ry)) then
GameSetCameraPos(x, y)
wait(CAPTURE_DELAY - 1)
UiHide = true -- Hide UI while capturing the screenshot
wait(1)
TriggerCapture(rx, ry)
UiHide = false
end end
x, y = x + CAPTURE_GRID_SIZE, y
local rectTopLeft, rectBottomRight = ScreenCapture.GetRect()
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)
---Top left in output coordinates.
---@type Vec2
local outputTopLeft = (topLeftWorld * outputPixelScale):Rounded()
-- Check if the file exists, and if we are allowed to overwrite it.
if dontOverwrite and Utils.FileExists(string.format("mods/noita-mapcap/output/%d,%d.png", outputTopLeft.x, outputTopLeft.y)) then
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
repeat
-- Prematurely stop capturing if that is requested by the context.
if ctx and ctx:IsStopping() then return end
if delayFrames > 30 then
-- Wiggle the screen a bit, as chunks sometimes don't want to load.
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 = 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 = 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)
-- 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
outputTopLeft = (topLeftWorld * outputPixelScale):Rounded()
else
outputTopLeft = topLeftWorld
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
error(string.format("failed to capture screenshot"))
end
-- Reset monitor and PC standby every screenshot.
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"
local function mapCapturingCtxErrHandler(err, scope)
print(string.format("Failed to capture map: %s.", err))
Message:ShowRuntimeError("MapCaptureError", "Failed to capture map:", tostring(err))
end
---Starts the capturing process in a spiral around origin.
---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? -- 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")
if file ~= nil then file:close() end
---Origin rounded to capture grid.
---@type Vec2
local origin = (origin / captureGridSize):Rounded("floor") * captureGridSize
---The position in world coordinates.
---Centered to the grid.
---@type Vec2
local pos = origin + Vec2(captureGridSize / 2, captureGridSize / 2)
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo(ctx)
Modification.SetCameraFree(true)
local i = 1
repeat
-- +x
for _ = 1, i, 1 do
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
pos:Add(Vec2(captureGridSize, 0))
end end
-- +y -- +y
for i = 1, i, 1 do for _ = 1, i, 1 do
local rx, ry = x * CAPTURE_PIXEL_SIZE, y * CAPTURE_PIXEL_SIZE captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
if not fileExists(string.format("mods/noita-mapcap/output/%d,%d.png", rx, ry)) then pos:Add(Vec2(0, captureGridSize))
GameSetCameraPos(x, y)
wait(CAPTURE_DELAY - 1)
UiHide = true
wait(1)
TriggerCapture(rx, ry)
UiHide = false
end
x, y = x, y + CAPTURE_GRID_SIZE
end end
i = i + 1 i = i + 1
-- -x -- -x
for i = 1, i, 1 do for _ = 1, i, 1 do
local rx, ry = x * CAPTURE_PIXEL_SIZE, y * CAPTURE_PIXEL_SIZE captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
if not fileExists(string.format("mods/noita-mapcap/output/%d,%d.png", rx, ry)) then pos:Add(Vec2(-captureGridSize, 0))
GameSetCameraPos(x, y)
wait(CAPTURE_DELAY - 1)
UiHide = true
wait(1)
TriggerCapture(rx, ry)
UiHide = false
end
x, y = x - CAPTURE_GRID_SIZE, y
end end
-- -y -- -y
for i = 1, i, 1 do for _ = 1, i, 1 do
local rx, ry = x * CAPTURE_PIXEL_SIZE, y * CAPTURE_PIXEL_SIZE captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
if not fileExists(string.format("mods/noita-mapcap/output/%d,%d.png", rx, ry)) then pos:Add(Vec2(0, -captureGridSize))
GameSetCameraPos(x, y)
wait(CAPTURE_DELAY - 1)
UiHide = true
wait(1)
TriggerCapture(rx, ry)
UiHide = false
end
x, y = x, y - CAPTURE_GRID_SIZE
end end
i = i + 1 i = i + 1
until ctx:IsStopping()
end 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 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 inclusive bottom right coordinate of the to be captured rectangle.
---@param captureGridSize number -- The grid size in world pixels.
---@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 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 = (validTopLeftWorld / captureGridSize):Rounded("floor"), ((validBottomRightWorld) / captureGridSize):Rounded("ceil") - Vec2(1, 1)
---Size of the rectangle in grid cells.
---@type Vec2
local gridSize = gridBottomRight - gridTopLeft
-- Hilbert curve can only fit into a square, so get the longest side.
local gridPOTSize = math.ceil(math.log(math.max(gridSize.x, gridSize.y)) / math.log(2))
-- Max size (Already rounded up to the next power of two).
local gridMaxSize = math.pow(2, gridPOTSize)
local t, tLimit = 0, gridMaxSize * gridMaxSize
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo(ctx)
Modification.SetCameraFree(true)
ctx.state = { Current = 0, Max = gridSize.x * gridSize.y }
while t < tLimit do
-- Prematurely stop capturing if that is requested by the context.
if ctx:IsStopping() then return end
---Position in grid coordinates.
---@type Vec2
local hilbertPos = Vec2(Hilbert.Map(t, gridPOTSize))
if hilbertPos.x < gridSize.x and hilbertPos.y < gridSize.y then
---Position in world coordinates.
---@type Vec2
local pos = (hilbertPos + gridTopLeft) * captureGridSize
pos:Add(captureOffset) -- Move to center of grid cell.
captureScreenshot(pos, true, true, ctx, outputPixelScale, captureDelay)
ctx.state.Current = ctx.state.Current + 1
end
t = t + 1
end
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 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 inclusive bottom right coordinate of the to be captured rectangle.
---@param captureGridSize number -- The grid size in world pixels.
---@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 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 = (validTopLeftWorld / captureGridSize):Rounded("floor"), ((validBottomRightWorld) / captureGridSize):Rounded("ceil") - Vec2(1, 1)
---Size of the rectangle in grid cells.
---@type Vec2
local gridSize = gridBottomRight - gridTopLeft
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo(ctx)
Modification.SetCameraFree(true)
ctx.state = { Current = 0, Max = gridSize.x * gridSize.y }
for gridY = gridTopLeft.y, gridBottomRight.y-1, 1 do
for gridX = gridTopLeft.x, gridBottomRight.x-1, 1 do
-- Prematurely stop capturing if that is requested by the context.
if ctx:IsStopping() then return end
---Position in grid coordinates.
---@type Vec2
local gridPos = Vec2(gridX, gridY)
---Position in world coordinates.
---@type Vec2
local pos = gridPos * captureGridSize
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
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 live capturing process.
---Use `Capture.MapCapturingCtx` to stop, control or view the process.
---@param outputPixelScale number? -- The resulting image pixel to world pixel ratio.
function Capture:StartCapturingLive(outputPixelScale)
---Queries the mod settings for the live capture parameters.
---@return integer interval -- The interval length in frames. Defaults to 30.
---@return number minDistanceSqr -- The minimum (squared) distance between screenshots. This will prevent screenshots if the player doesn't move much.
---@return number maxDistanceSqr -- The maximum (squared) distance between screenshots. This will allow more screenshots per interval if the player moves fast.
local function querySettings()
local interval = tonumber(ModSettingGet("noita-mapcap.live-interval")) or 30
local minDistance = tonumber(ModSettingGet("noita-mapcap.live-min-distance")) or 10
local maxDistance = tonumber(ModSettingGet("noita-mapcap.live-max-distance")) or 50
return interval, minDistance ^ 2, maxDistance ^ 2
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 oldPos
repeat
local interval, minDistanceSqr, maxDistanceSqr = querySettings()
-- Wait until we are allowed to take a new screenshot.
local delayFrames = 0
repeat
wait(0)
delayFrames = delayFrames + 1
local distanceSqr
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, nil)
oldPos = CameraAPI.GetPos()
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
---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*?
---@param modify boolean
---@param x number
---@param y number
---@param radius number
local function captureModifyEntities(file, modify, x, y, radius)
local entities = EntityAPI.GetInRadius(x, y, radius)
for _, entity in ipairs(entities) do
-- Get to the root entity, as we are exporting entire entity trees.
local rootEntity = entity:GetRootEntity() or entity
-- Make sure to only export entities when they are encountered the first time.
if file and not rootEntity:HasTag("MapCaptured") then
--print(rootEntity:GetFilename(), "got captured!")
-- Some hacky way to generate valid JSON that doesn't break when the game crashes.
-- Well, as long as it does not crash between write and flush.
if file:seek("end") == 0 then
-- First line.
file:write("[\n\t", JSON.Marshal(rootEntity), "\n", "]")
else
-- Following lines.
file:seek("end", -2) -- Seek a few bytes back, so we can overwrite some stuff.
file:write(",\n\t", JSON.Marshal(rootEntity), "\n", "]")
end
-- Disabling this component will prevent entities from being killed/reset when they go offscreen.
-- If they are reset, all tags will be reset and we may capture these entities multiple times.
-- This has some side effects, like longleg.xml and zombie_weak.xml will respawn every revisit, as their spawner doesn't get deleted. (Or something similar to this)
local components = rootEntity:GetComponents("CameraBoundComponent")
for _, component in ipairs(components) do
rootEntity:SetComponentsEnabled(component, false)
end
-- Prevent recapturing.
rootEntity:AddTag("MapCaptured")
end
-- Make sure to only modify entities when they are encountered the first time.
-- Also, don't modify the player.
if modify and not rootEntity:IsPlayer() and not rootEntity:HasTag("MapModified") then
-- Disable some components.
for _, componentTypeName in ipairs(Config.ComponentsToDisable) do
local components = rootEntity:GetComponents(componentTypeName)
for _, component in ipairs(components) do
rootEntity:SetComponentsEnabled(component, false)
end
end
-- Modify the gravity of every VelocityComponent, so stuff will not fall.
local component = rootEntity:GetFirstComponent("VelocityComponent")
if component then
component:SetValue("gravity_x", 0)
component:SetValue("gravity_y", 0)
component:SetValue("mVelocity", 0, 0)
end
-- Modify the gravity of every CharacterPlatformingComponent, so mobs will not fall.
local component = rootEntity:GetFirstComponent("CharacterPlatformingComponent")
if component then
component:SetValue("pixel_gravity", 0)
end
-- Disable the hover and spinning animations of every ItemComponent.
local component = rootEntity:GetFirstComponent("ItemComponent")
if component then
component:SetValue("play_hover_animation", false)
component:SetValue("play_spinning_animation", false)
end
-- Disable the hover animation of cards. Disabling the "SpriteOffsetAnimatorComponent" does not help.
--[[local components = rootEntity:GetComponents("SpriteOffsetAnimatorComponent")
for _, component in ipairs(components) do
component:SetValue("x_speed", 0)
component:SetValue("y_speed", 0)
component:SetValue("x_amount", 0)
component:SetValue("y_amount", 0)
end]]
-- Try to prevent some stuff from exploding.
local component = rootEntity:GetFirstComponent("PhysicsBody2Component")
if component then
component:SetValue("kill_entity_if_body_destroyed", false)
component:SetValue("destroy_body_if_entity_destroyed", false)
component:SetValue("auto_clean", false)
end
-- Try to prevent some stuff from exploding.
local component = rootEntity:GetFirstComponent("DamageModelComponent")
if component then
component:SetValue("falling_damages", false)
end
-- Try to prevent some stuff from exploding.
local component = rootEntity:GetFirstComponent("ExplodeOnDamageComponent")
if component then
component:SetValue("explode_on_death_percent", 0)
end
-- Try to prevent some stuff from exploding.
local component = rootEntity:GetFirstComponent("MaterialInventoryComponent")
if component then
component:SetValue("on_death_spill", false)
component:SetValue("kill_when_empty", false)
end
-- Prevent it from being modified again.
rootEntity:AddTag("MapModified")
-- Just a test on how to remove/kill creatures and enemies.
--if (rootEntity:HasTag("enemy") or rootEntity:HasTag("helpless_animal")) and not rootEntity:HasTag("boss") then
-- rootEntity:Kill()
--end
end
end
-- Ensure everything is written to disk before noita decides to crash.
if file then
file:flush()
end
end
---
---@return file*?
local function createOrOpenEntityCaptureFile()
-- Make sure the file exists.
local file = io.open("mods/noita-mapcap/output/entities.json", "a")
if file ~= nil then file:close() end
-- Create or reopen entities JSON file.
file = io.open("mods/noita-mapcap/output/entities.json", "r+b") -- Open for reading (r) and writing (+) in binary mode. r+b will not truncate the file to 0.
if file == nil then return nil end
return file
end
---Starts entity capturing and modification.
---Use `Capture.EntityCapturingCtx` to stop, control or view the progress.
---@param store boolean -- Will create a file and write all encountered entities into it.
---@param modify boolean -- Will modify all encountered entities.
function Capture:StartCapturingEntities(store, modify)
-- There is nothing to capture, don't start anything.
if not store and not modify then return end
local file
---Process initialization callback.
---@param ctx ProcessRunnerCtx
local function handleInit(ctx)
-- Create output file if requested.
file = store and createOrOpenEntityCaptureFile() or nil
end
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo(ctx)
repeat
local pos, radius = CameraAPI:GetPos(), 5000 -- Returns the virtual coordinates of the screen center.
captureModifyEntities(file, modify, pos.x, pos.y, radius)
wait(0)
until ctx:IsStopping()
end
---Process end callback.
---@param ctx ProcessRunnerCtx
local function handleEnd(ctx)
if file then file:close() end
end
---Error handler callback.
---@param err string
---@param scope "init"|"do"|"end"
local function handleErr(err, scope)
print(string.format("Failed to capture entities: %s", err))
Message:ShowRuntimeError("EntitiesCaptureError", "Failed to capture entities:", tostring(err))
end
-- Run process, if there is no other running right now.
self.EntityCapturingCtx:Run(handleInit, handleDo, handleEnd, handleErr)
end
---Writes the current player position and other stats onto disk.
---@param file file*?
---@param pos Vec2
---@param oldPos Vec2
---@param hp number
---@param maxHP number
---@param polymorphed boolean
local function writePlayerPathEntry(file, pos, oldPos, hp, maxHP, polymorphed)
if not file then return end
local struct = {
from = oldPos,
to = pos,
hp = hp,
maxHP = maxHP,
polymorphed = polymorphed,
}
-- Some hacky way to generate valid JSON that doesn't break when the game crashes.
-- Well, as long as it does not crash between write and flush.
if file:seek("end") == 0 then
-- First line.
file:write("[\n\t", JSON.Marshal(struct), "\n", "]")
else
-- Following lines.
file:seek("end", -2) -- Seek a few bytes back, so we can overwrite some stuff.
file:write(",\n\t", JSON.Marshal(struct), "\n", "]")
end
-- Ensure everything is written to disk before noita decides to crash.
file:flush()
end
---
---@return file*?
local function createOrOpenPlayerPathCaptureFile()
-- Make sure the file exists.
local file = io.open("mods/noita-mapcap/output/player-path.json", "a")
if file ~= nil then file:close() end
-- Create or reopen JSON file.
file = io.open("mods/noita-mapcap/output/player-path.json", "r+b") -- Open for reading (r) and writing (+) in binary mode. r+b will not truncate the file to 0.
if file == nil then return nil end
return file
end
---Starts capturing the player path.
---Use `Capture.PlayerPathCapturingCtx` to stop, control or view the progress.
---@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
if outputPixelScale == 0 or outputPixelScale == nil then
outputPixelScale = Coords:PixelScale()
end
local file
local oldPos
---Process initialization callback.
---@param ctx ProcessRunnerCtx
local function handleInit(ctx)
-- Create output file if requested.
file = createOrOpenPlayerPathCaptureFile()
end
---Process main callback.
---@param ctx ProcessRunnerCtx
local function handleDo(ctx)
repeat
-- Get player entity, even if it is polymorphed.
-- For some reason Noita crashes when querying the "is_player" GameStatsComponent value on a freshly polymorphed entity found by its "player_unit" tag.
-- 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?
local playerEntity
-- Try to find the regular player entity.
for _, entity in ipairs(EntityAPI.GetWithTag("player_unit")) do
playerEntity = entity
break
end
-- If no player_unit entity was found, check if the player is any of the polymorphed entities.
if not playerEntity then
for _, entity in ipairs(EntityAPI.GetWithTag("polymorphed")) do
local gameStatsComponent = entity:GetFirstComponent("GameStatsComponent")
if gameStatsComponent and gameStatsComponent:GetValue("is_player") then
playerEntity = entity
break
end
end
end
-- Found some player entity.
if playerEntity then
-- Get position.
local x, y, rotation, scaleX, scaleY = playerEntity:GetTransform()
local pos = Vec2(x, y) * outputPixelScale
-- Get some other stats from the player.
local damageModel = playerEntity:GetFirstComponent("DamageModelComponent")
local hp, maxHP
if damageModel then
hp, maxHP = damageModel:GetValue("hp"), damageModel:GetValue("max_hp")
end
local polymorphed = playerEntity:HasTag("polymorphed")
if oldPos then writePlayerPathEntry(file, pos, oldPos, hp, maxHP, polymorphed) end
oldPos = pos
end
wait(interval)
until ctx:IsStopping()
end
---Process end callback.
---@param ctx ProcessRunnerCtx
local function handleEnd(ctx)
if file then file:close() end
end
---Error handler callback.
---@param err string
---@param scope "init"|"do"|"end"
local function handleErr(err, scope)
print(string.format("Failed to capture player path: %s", err))
Message:ShowRuntimeError("PlayerPathCaptureError", "Failed to capture player path:", tostring(err))
end
-- Run process, if there is no other running right now.
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()
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, captureDelay)
else
---@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
end
elseif mode == "spiral" then
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, captureDelay)
elseif origin == "0" then
local originVec = Vec2(0, 0)
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay)
elseif origin == "current" then
local originVec = CameraAPI:GetPos()
self:StartCapturingSpiral(originVec, captureGridSize, outputPixelScale, captureDelay)
else
Message:ShowRuntimeError("SpiralOrigin", string.format("Unknown spiral origin %q", tostring(origin)))
end
else
Message:ShowRuntimeError("StartCapturing", string.format("Unknown capturing mode %q", tostring(mode)))
end
-- Start entity capturing and modification, if wanted.
local captureEntities = ModSettingGet("noita-mapcap.capture-entities")
local modifyEntities = ModSettingGet("noita-mapcap.modify-entities")
self:StartCapturingEntities(captureEntities, modifyEntities)
end)
end
---Stops all capturing processes.
function Capture:StopCapturing()
self.EntityCapturingCtx:Stop()
self.MapCapturingCtx:Stop()
self.PlayerPathCapturingCtx:Stop()
end end

135
files/check.lua Normal file
View File

@ -0,0 +1,135 @@
-- Copyright (c) 2019-2024 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Check if everything is alright.
-- This does mainly trigger user messages and suggest actions.
-----------------------
-- Load global stuff --
-----------------------
--------------------------
-- Load library modules --
--------------------------
local Coords = require("coordinates")
local ScreenCap = require("screen-capture")
local Vec2 = require("noita-api.vec2")
local Utils = require("noita-api.utils")
----------
-- Code --
----------
---Runs a list of checks at addon startup.
function Check:Startup()
if Utils.FileExists("mods/noita-mapcap/output/nonempty") then
Message:ShowOutputNonEmpty()
end
if not Utils.FileExists("mods/noita-mapcap/bin/capture-b/capture.dll") then
Message:ShowGeneralInstallationProblem("`capture.dll` is missing.", "Make sure you have installed the mod correctly.")
end
if not Utils.FileExists("mods/noita-mapcap/bin/stitch/stitch.exe") then
Message:ShowGeneralInstallationProblem("`stitch.exe` is missing.", "Make sure you have installed the mod correctly.", " ", "You can still use the mod to capture, though.")
end
end
---Regularly runs a list of checks.
---@param interval integer -- Check interval in frames.
function Check:Regular(interval)
interval = interval or 60
self.Counter = (self.Counter or 0) - 1
if self.Counter > 0 then return end
self.Counter = interval
-- Remove some messages, so they will automatically disappear when the problem is solved.
Message:CloseAutoClose()
-- Compare Noita config and actual window resolution.
local topLeft, bottomRight = ScreenCap.GetRect() -- Actual window client area.
if topLeft and bottomRight then
local actual = bottomRight - topLeft
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.")
end
-- Check if we have the required settings.
local config, magic, patches = Modification.RequiredChanges()
if config["fullscreen"] then
local expected = tonumber(config["fullscreen"])
if expected ~= Coords.FullscreenMode then
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Fullscreen mode %s. Expected %s.", Coords.FullscreenMode, expected))
end
end
if config["window_w"] and config["window_h"] then
local expected = Vec2(tonumber(config["window_w"]), tonumber(config["window_h"]))
if expected ~= Coords.WindowResolution then
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Window resolution is %s. Expected %s.", Coords.WindowResolution, expected))
end
end
if config["internal_size_w"] and config["internal_size_h"] then
local expected = Vec2(tonumber(config["internal_size_w"]), tonumber(config["internal_size_h"]))
if expected ~= Coords.InternalResolution then
Message:ShowSetNoitaSettings(Modification.AutoSet, string.format("Internal resolution is %s. Expected %s.", Coords.InternalResolution, expected))
end
end
if config["screenshake_intensity"] then
local expected = config.screenshake_intensity
if expected ~= self.StartupConfig.screenshake_intensity then
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
local expected = Vec2(tonumber(magic["VIRTUAL_RESOLUTION_X"]), tonumber(magic["VIRTUAL_RESOLUTION_Y"]))
if expected ~= Coords.VirtualResolution then
Message:ShowRequestRestart(string.format("Virtual resolution is %s. Expected %s.", Coords.VirtualResolution, expected))
end
end
if magic["VIRTUAL_RESOLUTION_OFFSET_X"] and magic["VIRTUAL_RESOLUTION_OFFSET_Y"] then
local expected = Vec2(tonumber(magic["VIRTUAL_RESOLUTION_OFFSET_X"]), tonumber(magic["VIRTUAL_RESOLUTION_OFFSET_Y"]))
if expected ~= Coords.VirtualOffset then
Message:ShowRequestRestart(string.format("Virtual offset is %s. Expected %s.", Coords.VirtualOffset, expected))
end
end
-- Request a restart if the user has changed specific mod settings.
local restartModSettings = {"disable-background", "disable-ui", "disable-physics", "disable-postfx"}
for i, v in ipairs(restartModSettings) do
local settingID = "noita-mapcap." .. v
if ModSettingGetNextValue(settingID) ~= ModSettingGet(settingID) then
Message:ShowRequestRestart(string.format("Setting %s got changed from %s to %s.", v, tostring(ModSettingGet(settingID)), tostring(ModSettingGetNextValue(settingID))))
end
end
-- Check if capture grid size is smaller than the virtual resolution.
-- 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 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.",
" ",
"Apply either of the following in the mod settings:",
string.format("- Set the grid size to at most %s.", math.min(Coords.VirtualResolution.x, Coords.VirtualResolution.y)),
string.format("- Increase the custom resolutions to at least %s in any direction.", captureGridSize),
"- Change capture mode to `live`."
)
end
end

View File

@ -1,39 +0,0 @@
-- Copyright (c) 2019 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Some code to make noita's lua more conform to standard lua
-- Globally overwrite print function to behave more like expected
local oldPrint = print
function print(...)
local arg = {...}
stringArgs = {}
for i, v in ipairs(arg) do
table.insert(stringArgs, tostring(v))
end
oldPrint(unpack(stringArgs))
end
-- Overwrite print to copy its output into a file
--[[local logFile = io.open("lualog.txt", "w")
function print(...)
local arg = {...}
stringArgs = {}
local result = ""
for i, v in ipairs(arg) do
table.insert(stringArgs, tostring(v))
result = result .. tostring(v) .. "\t"
end
result = result .. "\n"
logFile:write(result)
logFile:flush()
oldPrint(unpack(stringArgs))
end]]

92
files/config.lua Normal file
View File

@ -0,0 +1,92 @@
-- 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.
-- This is only used when modifying entities, not when capturing/storing them.
Config.ComponentsToDisable = {
"AnimalAIComponent",
"SimplePhysicsComponent",
"CharacterPlatformingComponent",
"WormComponent",
"WormAIComponent",
--"CameraBoundComponent", -- This is already removed when capturing/storing entities. Not needed when we only modify entities.
--"PhysicsBodyCollisionDamageComponent",
--"ExplodeOnDamageComponent",
--"DamageModelComponent",
--"SpriteOffsetAnimatorComponent",
--"MaterialInventoryComponent",
--"LuaComponent",
--"PhysicsBody2Component", -- Disabling will hide barrels and similar stuff, also triggers an assertion.
--"PhysicsBodyComponent",
--"VelocityComponent", -- Disabling this component may cause a "...\component_updators\advancedfishai_system.cpp at line 107" exception.
--"SpriteComponent",
--"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"] = getBaseArea,
-- Main world: The main world with 3 parts: sky, normal and hell.
["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"] = 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,18 +0,0 @@
-- Copyright (c) 2019 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
local ffi = ffi or _G.ffi or require("ffi")
local status, caplib = pcall(ffi.load, "mods/noita-mapcap/bin/capture-b/capture")
if not status then
print("Error loading capture lib: " .. cap)
end
ffi.cdef [[
void Capture(int x, int y);
]]
function TriggerCapture(x, y)
caplib.Capture(x, y)
end

View File

@ -1,15 +0,0 @@
-- Copyright (c) 2019 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
if not async then
dofile("data/scripts/lib/coroutines.lua")
end
dofile("data/scripts/perks/perk_list.lua")
dofile("mods/noita-mapcap/files/compatibility.lua")
dofile("mods/noita-mapcap/files/util.lua")
dofile("mods/noita-mapcap/files/external.lua")
dofile("mods/noita-mapcap/files/capture.lua")
dofile("mods/noita-mapcap/files/ui.lua")

View File

@ -0,0 +1,278 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Pixel perfect viewport coordinates transformation (world <-> window) for Noita.
-- For it to work, you have to:
-- - Put Coords:ReadResolutions() inside of the OnMagicNumbersAndWorldSeedInitialized() hook.
-- Some general information on how Noita does that stuff internally:
-- - The base for all calculations is the window rectangle (window client area).
-- - Inside the window there is the internal rectangle that is fit to be fully contained and centered inside the window.
-- - Inside the internal rectangle there is the virtual rectangle that is aligned to the top, and scaled to fit horizontally.
-- - Everything outside the internal rectangle is black.
-- - Everything outside the virtual rectangle is not rendered correctly.
-- - A positive virtual offset moves the rendered world to the top left.
-- - GameGetCameraBounds returned coordinates are off by a few pixels, also it doesn't have sub pixel precision.
-- - The mouse cursor coordinates in the dev build use the wrong rounding method (They are rounded towards zero, instead of being rounded towards negative infinity).
-- - Integer world coordinates map exactly to pixel borders.
-- - The default image ratios of the virtual and internal rectangles don't exactly match, which causes a small line of not correctly rendered pixels at the bottom window.
-- - The GRID_RENDER_BORDER magic number adds the given amount of world pixels to the virtual rectangle's width. This happens after fitting, so a positive value will make it wider than the internal rectangle. The virtual rectangle will always be aligned to start at the left side of the internal rectangle, though.
-- - The virtual offset needs to be [-GRID_RENDER_BORDER, 0] for the viewport center to be exactly centered to the window or virtual rectangle.
--------------------------
-- Load library modules --
--------------------------
local CameraAPI = require("noita-api.camera")
local NXML = require("luanxml.nxml")
local Utils = require("noita-api.utils")
local Vec2 = require("noita-api.vec2")
----------
-- Code --
----------
---@class Coords
---@field InternalResolution Vec2 -- Size of the internal rectangle in window pixels.
---@field WindowResolution Vec2 -- Size of the window client area in window pixels.
---@field VirtualResolution Vec2 -- Size of the virtual rectangle in world/virtual pixels.
---@field VirtualOffset Vec2 -- Offset of the virtual rectangle in world/virtual pixels.
---@field VirtualBorder number -- The magic number "GRID_RENDER_BORDER" in world/virtual pixels.
---@field FullscreenMode integer -- The fullscreen mode the game is in. 0 is windowed.
local Coords = {
InternalResolution = Vec2(0, 0),
WindowResolution = Vec2(0, 0),
VirtualResolution = Vec2(0, 0),
VirtualOffset = Vec2(0, 0),
VirtualBorder = 0,
FullscreenMode = 0,
}
---Reads and updates the internal, window and virtual resolutions from Noita's config files and API.
---@return any error
function Coords:ReadResolutions()
local filename = Utils.GetSpecialDirectory("save-shared") .. "config.xml"
local f, err = io.open(filename, "r")
if not f then return err end
local xml = NXML.parse(f:read("*a"))
self.WindowResolution = Vec2(tonumber(xml.attr["window_w"]), tonumber(xml.attr["window_h"]))
self.InternalResolution = Vec2(tonumber(xml.attr["internal_size_w"]), tonumber(xml.attr["internal_size_h"]))
self.VirtualResolution = Vec2(tonumber(MagicNumbersGetValue("VIRTUAL_RESOLUTION_X")), tonumber(MagicNumbersGetValue("VIRTUAL_RESOLUTION_Y")))
self.VirtualOffset = Vec2(tonumber(MagicNumbersGetValue("VIRTUAL_RESOLUTION_OFFSET_X")), tonumber(MagicNumbersGetValue("VIRTUAL_RESOLUTION_OFFSET_Y")))
self.VirtualBorder = tonumber(MagicNumbersGetValue("GRID_RENDER_BORDER")) or 0
self.FullscreenMode = tonumber(xml.attr["fullscreen"]) or 0
f:close()
return nil
end
---Returns the size of the internal rectangle in window/screen coordinates.
---The internal rect is always uniformly scaled to fit inside the window rectangle.
---@return Vec2
function Coords:InternalRectSize()
return self.InternalResolution * math.min(self.WindowResolution.x / self.InternalResolution.x, self.WindowResolution.y / self.InternalResolution.y)
end
---Returns the window coordinates of the internal rectangle in window/screen coordinates.
---This rectangle is centered and scaled to fit exactly into the window rectangle.
---@return Vec2 topLeft
---@return Vec2 bottomRight -- These coordinates are outside of the rectangle.
function Coords:InternalRect()
local internalRectSize = self:InternalRectSize()
-- Center rectangle and return corner points.
---@type Vec2
local halfDifference = (self.WindowResolution - internalRectSize) / 2
return halfDifference, internalRectSize + halfDifference
end
---Returns the virtual rectangle coordinates in window/screen coordinates.
---This is the rectangle that has all chunks and terrain rendered correctly.
---The rectangle may be larger than the screen, though.
---@return Vec2 topLeft
---@return Vec2 bottomRight -- These coordinates are outside of the rectangle.
function Coords:VirtualRect()
local internalTopLeft, internalBottomRight = self:InternalRect()
return internalTopLeft, internalTopLeft + self.VirtualResolution * self:PixelScale()
end
---Returns the rectangle that contains valid rendered terrain and chunks.
---This is cropped to the internal rectangle, and can be used to determine the usable area of window screenshots.
---@return Vec2 topLeft
---@return Vec2 bottomRight -- These coordinates are outside of the rectangle.
function Coords:ValidRenderingRect()
local internalTopLeft, internalBottomRight = self:InternalRect()
local virtualTopLeft, virtualBottomRight = self:VirtualRect()
return virtualTopLeft, Vec2(math.min(virtualBottomRight.x, internalBottomRight.x), math.min(virtualBottomRight.y, internalBottomRight.y))
end
---Returns the ratio of window pixels per world pixels.
---As pixels are always square, this returns just a single number.
---@return number
function Coords:PixelScale()
local internalRectSize = self:InternalRectSize()
-- The virtual rectangle is always scaled to fit horizontally.
return internalRectSize.x / self.VirtualResolution.x
end
---Converts the given virtual/world coordinates into window/screen coordinates.
---@param world Vec2 -- World coordinate, origin is near the cave entrance.
---@param viewportCenter Vec2|nil -- Result of `GameGetCameraPos()`. Will be queried automatically if set to nil.
---@return Vec2 window
function Coords:ToWindow(world, viewportCenter)
viewportCenter = viewportCenter or CameraAPI.GetPos()
local internalTopLeft, internalBottomRight = self:InternalRect()
local pixelScale = self:PixelScale()
return internalTopLeft + (self.VirtualResolution / 2 + world - viewportCenter + Vec2(self.VirtualBorder, 0) + self.VirtualOffset) * pixelScale
end
---Converts the given window coordinates into world/virtual coordinates.
---@param window Vec2 -- In screen pixels, origin is at the top left of the window client area.
---@param viewportCenter Vec2|nil -- Result of `GameGetCameraPos()`. Will be queried automatically if set to nil.
---@return Vec2 world
function Coords:ToWorld(window, viewportCenter)
viewportCenter = viewportCenter or CameraAPI.GetPos()
local internalTopLeft, internalBottomRight = self:InternalRect()
local pixelScale = self:PixelScale()
return viewportCenter - self.VirtualResolution / 2 + (window - internalTopLeft) / pixelScale - Vec2(self.VirtualBorder, 0) - self.VirtualOffset
end
-------------
-- Testing --
-------------
---Values to test the coordinate transformations.
---
--- Configuration (`...\save_shared\config.xml`) parameters:
--- - `backbuffer_width`, `backbuffer_height`: The resolution for the final pixel shader, or something like that. Lowering this will not change the coordinate system, but make everything look more pixelated. The backbuffer size should be set at least to the internal resolution, Noita sets it to the window resolution.
--- - `internal_size_w`, `internal_size_h`: The rectangle that all window content will be displayed in. If the window ratio is different than the internal size ratio, there will be black bars either at the top and bottom, or left and right.
--- - `window_w`, `window_h`: The window client area size in pixels, duh.
---
--- Magic numbers (`.\mods\noita-mapcap\files\magic_numbers.xml`):
--- - `VIRTUAL_RESOLUTION_X`, `VIRTUAL_RESOLUTION_X`: The resolution of the rendered world.
--- - `VIRTUAL_RESOLUTION_OFFSET_X`, `VIRTUAL_RESOLUTION_OFFSET_Y`: Offset of the world/virtual coordinate system, has to be set to `-2, 0` to map pixel perfect to the screen.
---
--- Table contents:
---
--- - `InternalRes`, `WindowRes`, `VirtualRes`, `VirtualBorder` -- are the settings from the above mentioned config files.
--- - `WindowTopLeft` contains the resulting world coordinates of the window's top left pixel with GameSetCameraPos(0, 0).
--- - `WindowCenter` contains the resulting world coordinates of the window's center pixel with GameSetCameraPos(0, 0).
--- - `RenderedTopLeft`, `RenderedBottomRight` describe the rectangle in world coordinates that contains correctly rendered chunks. Everything outside this rectangle may either just be a blank background image or completely black.
local testTable = {
{ InternalRes = Vec2(1024, 1024), WindowRes = Vec2(1024, 1024), VirtualRes = Vec2(1024, 1024), VirtualBorder = 2, WindowTopLeft = Vec2(-512, -512), WindowCenter = Vec2(0, 0), RenderedTopLeft = Vec2(-512, -512), RenderedBottomRight = Vec2(512, 512) },
{ InternalRes = Vec2(1024, 1024), WindowRes = Vec2(1024, 1024), VirtualRes = Vec2(512, 1024), VirtualBorder = 2, WindowTopLeft = Vec2(-256, -512), WindowCenter = Vec2(0, -256), RenderedTopLeft = Vec2(-256, -512), RenderedBottomRight = Vec2(256, 0) },
{ InternalRes = Vec2(1024, 1024), WindowRes = Vec2(1024, 1024), VirtualRes = Vec2(1024, 512), VirtualBorder = 2, WindowTopLeft = Vec2(-512, -256), WindowCenter = Vec2(0, 256), RenderedTopLeft = Vec2(-512, -256), RenderedBottomRight = Vec2(512, 256) },
{ InternalRes = Vec2(512, 1024), WindowRes = Vec2(1024, 1024), VirtualRes = Vec2(1024, 1024), VirtualBorder = 2, WindowTopLeft = Vec2(-1024, -512), WindowCenter = Vec2(0, 512), RenderedTopLeft = Vec2(-512, -512), RenderedBottomRight = Vec2(512, 512) },
{ InternalRes = Vec2(1024, 512), WindowRes = Vec2(1024, 1024), VirtualRes = Vec2(1024, 1024), VirtualBorder = 2, WindowTopLeft = Vec2(-512, -768), WindowCenter = Vec2(0, -256), RenderedTopLeft = Vec2(-512, -512), RenderedBottomRight = Vec2(512, 0) },
{ InternalRes = Vec2(1024, 1024), WindowRes = Vec2(1024, 2048), VirtualRes = Vec2(1024, 1024), VirtualBorder = 2, WindowTopLeft = Vec2(-512, -1024), WindowCenter = Vec2(0, 0), RenderedTopLeft = Vec2(-512, -512), RenderedBottomRight = Vec2(512, 512) },
{ InternalRes = Vec2(1024, 1024), WindowRes = Vec2(2048, 1024), VirtualRes = Vec2(1024, 1024), VirtualBorder = 2, WindowTopLeft = Vec2(-1024, -512), WindowCenter = Vec2(0, 0), RenderedTopLeft = Vec2(-512, -512), RenderedBottomRight = Vec2(512, 512) },
{ InternalRes = Vec2(1024, 512), WindowRes = Vec2(1024, 512), VirtualRes = Vec2(1024, 1024), VirtualBorder = 2, WindowTopLeft = Vec2(-512, -512), WindowCenter = Vec2(0, -256), RenderedTopLeft = Vec2(-512, -512), RenderedBottomRight = Vec2(512, 0) },
{ InternalRes = Vec2(2048, 1024), WindowRes = Vec2(2048, 1024), VirtualRes = Vec2(1024, 1024), VirtualBorder = 2, WindowTopLeft = Vec2(-512, -512), WindowCenter = Vec2(0, -256), RenderedTopLeft = Vec2(-512, -512), RenderedBottomRight = Vec2(512, 0) },
{ InternalRes = Vec2(2048, 1024), WindowRes = Vec2(2048, 1024), VirtualRes = Vec2(2048, 1024), VirtualBorder = 2, WindowTopLeft = Vec2(-1024, -512), WindowCenter = Vec2(0, 0), RenderedTopLeft = Vec2(-1024, -512), RenderedBottomRight = Vec2(1024, 512) },
{ InternalRes = Vec2(2048, 1024), WindowRes = Vec2(2048, 1024), VirtualRes = Vec2(512, 2048), VirtualBorder = 2, WindowTopLeft = Vec2(-256, -1024), WindowCenter = Vec2(0, -896), RenderedTopLeft = Vec2(-256, -1024), RenderedBottomRight = Vec2(256, -768) },
{ InternalRes = Vec2(2048, 1024), WindowRes = Vec2(2048, 1024), VirtualRes = Vec2(1024, 16), VirtualBorder = 2, WindowTopLeft = Vec2(-512, -8), WindowCenter = Vec2(0, 248), RenderedTopLeft = Vec2(-512, -8), RenderedBottomRight = Vec2(512, 8) },
{ InternalRes = Vec2(1024, 1024), WindowRes = Vec2(1024, 1024), VirtualRes = Vec2(32, 16), VirtualBorder = 2, WindowTopLeft = Vec2(-16, -8), WindowCenter = Vec2(0, 8), RenderedTopLeft = Vec2(-16, -8), RenderedBottomRight = Vec2(16, 8) },
{ InternalRes = Vec2(1280, 720), WindowRes = Vec2(1920, 1080), VirtualRes = Vec2(427, 242), VirtualBorder = 2, WindowTopLeft = Vec2(-213.5, -121), WindowCenter = Vec2(0, -0.90625), RenderedTopLeft = Vec2(-213.5, -121), RenderedBottomRight = Vec2(213.5, 119.1875) },
{ InternalRes = Vec2(1280, 720), WindowRes = Vec2(1920, 1200), VirtualRes = Vec2(427, 242), VirtualBorder = 2, WindowTopLeft = Vec2(-213.5, -134.34375), WindowCenter = Vec2(0, -0.90625), RenderedTopLeft = Vec2(-213.5, -121), RenderedBottomRight = Vec2(213.5, 119.1875) },
{ InternalRes = Vec2(1280, 720), WindowRes = Vec2(2048, 1080), VirtualRes = Vec2(427, 242), VirtualBorder = 2, WindowTopLeft = Vec2(-227.73333, -121), WindowCenter = Vec2(0, -0.90625), RenderedTopLeft = Vec2(-213.5, -121), RenderedBottomRight = Vec2(213.5, 119.1875) },
}
---Tests all possible test cases.
---Throws an error in case any test fails.
local function testToWindow()
for i, v in ipairs(testTable) do
Coords.InternalResolution, Coords.WindowResolution, Coords.VirtualResolution, Coords.VirtualBorder = v.InternalRes, v.WindowRes, v.VirtualRes, v.VirtualBorder
---@type Vec2
local viewportCenter = Vec2(0, 0)
---@type Vec2, Vec2
local world, expected = v.WindowTopLeft, Vec2(0, 0)
local window = Coords:ToWindow(world, viewportCenter)
assert(window:EqualTo(expected, 0.001), string.format("test case %d: Coords:ToWindow(%q) failed. Got %q, expected %q", i, tostring(world), tostring(window), tostring(expected)))
---@type Vec2, Vec2
local world, expected = v.WindowCenter, v.WindowRes / 2
local window = Coords:ToWindow(world, viewportCenter)
assert(window:EqualTo(expected, 0.001), string.format("test case %d: Coords:ToWindow(%q) failed. Got %q, expected %q", i, tostring(world), tostring(window), tostring(expected)))
end
end
---Tests all possible test cases.
---Throws an error in case any test fails.
local function testToWorld()
for i, v in ipairs(testTable) do
Coords.InternalResolution, Coords.WindowResolution, Coords.VirtualResolution, Coords.VirtualBorder = v.InternalRes, v.WindowRes, v.VirtualRes, v.VirtualBorder
---@type Vec2
local viewportCenter = Vec2(0, 0)
---@type Vec2, Vec2
local window, expected = Vec2(0, 0), v.WindowTopLeft
local world = Coords:ToWorld(window, viewportCenter)
assert(world:EqualTo(expected, 0.001), string.format("test case %d: Coords:ToWorld(%q) failed. Got %q, expected %q", i, tostring(window), tostring(world), tostring(expected)))
---@type Vec2, Vec2
local window, expected = v.WindowRes / 2, v.WindowCenter
local world = Coords:ToWorld(window, viewportCenter)
assert(world:EqualTo(expected, 0.001), string.format("test case %d: Coords:ToWorld(%q) failed. Got %q, expected %q", i, tostring(window), tostring(world), tostring(expected)))
end
end
---Tests all possible test cases.
---Throws an error in case any test fails.
local function testValidRenderingRect()
for i, v in ipairs(testTable) do
Coords.InternalResolution, Coords.WindowResolution, Coords.VirtualResolution, Coords.VirtualBorder = v.InternalRes, v.WindowRes, v.VirtualRes, v.VirtualBorder
---@type Vec2
local viewportCenter = Vec2(0, 0)
local expectedTopLeft, expectedBottomRight = v.RenderedTopLeft, v.RenderedBottomRight
---@type Vec2, Vec2
local validTopLeft, validBottomRight = Coords:ValidRenderingRect()
local validTopLeftWorld, validBottomRightWorld = Coords:ToWorld(validTopLeft, viewportCenter), Coords:ToWorld(validBottomRight, viewportCenter)
assert(validTopLeftWorld:EqualTo(expectedTopLeft, 0.001) and validBottomRightWorld:EqualTo(expectedBottomRight, 0.001),
string.format("test case %d: Coords:ValidRenderingRect() failed. Got %q - %q, expected %q - %q",
i, tostring(validTopLeftWorld), tostring(validBottomRightWorld), tostring(expectedTopLeft), tostring(expectedBottomRight)
)
)
end
end
---Runs all tests of this module.
local function testAll()
local ok, err = pcall(testToWindow)
if not ok then
print(string.format("testToWindow failed: %s.", err))
end
local ok, err = pcall(testToWorld)
if not ok then
print(string.format("testToWorld failed: %s.", err))
end
local ok, err = pcall(testValidRenderingRect)
if not ok then
print(string.format("testValidRenderingRect failed: %s.", err))
end
end
--testAll()
return Coords

View File

@ -0,0 +1,64 @@
-- Copyright (c) 2019-2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
local Hilbert = {}
---Rotate/flip quadrant.
---@param n integer
---@param x integer
---@param y integer
---@param rx boolean
---@param ry boolean
---@return integer
---@return integer
local function hilbertRotate(n, x, y, rx, ry)
if not ry then
if rx then
x = n - 1 - x
y = n - 1 - y
end
x, y = y, x
end
return x, y
end
---Maps t in the range of [0, (2^potSize)^2-1] to a position on the hilbert curve with the side length of 2^potSize (Power of two size).
---@param t integer
---@param potSize integer
---@return integer
---@return integer
function Hilbert.Map(t, potSize)
local size = math.pow(2, potSize)
local x, y = 0, 0
if t < 0 or t >= size * size then
error("variable t is outside of the range")
end
for i = 0, potSize - 1, 1 do
local iPOT = math.pow(2, i)
local rx = bit.band(t, 2) == 2
local ry = bit.band(t, 1) == 1
if rx then
ry = not ry
end
x, y = hilbertRotate(iPOT, x, y, rx, ry)
if rx then
x = x + iPOT
end
if ry then
y = y + iPOT
end
t = math.floor(t / 4)
end
return x, y
end
return Hilbert

@ -0,0 +1 @@
Subproject commit 03d28907ccced296e5b2f8b16303a312ab4eaa3b

View File

@ -0,0 +1,58 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
local ffi = require("ffi")
local Memory = {}
if ffi.abi'64bit' then
ffi.cdef([[
typedef uint64_t __uint3264;
]])
else
ffi.cdef([[
typedef uint32_t __uint3264;
]])
end
ffi.cdef([[
typedef void VOID;
typedef VOID *LPVOID;
typedef int BOOL;
typedef __uint3264 ULONG_PTR, *PULONG_PTR;
typedef ULONG_PTR SIZE_T, *PSIZE_T;
typedef unsigned long DWORD;
typedef DWORD *PDWORD;
BOOL VirtualProtect(LPVOID lpAddress, SIZE_T dwSize, DWORD flNewProtect, PDWORD lpflOldProtect);
]])
Memory.PAGE_NOACCESS = 0x01
Memory.PAGE_READONLY = 0x02
Memory.PAGE_READWRITE = 0x04
Memory.PAGE_WRITECOPY = 0x08
Memory.PAGE_EXECUTE = 0x10
Memory.PAGE_EXECUTE_READ = 0x20
Memory.PAGE_EXECUTE_READWRITE = 0x40
Memory.PAGE_EXECUTE_WRITECOPY = 0x80
Memory.PAGE_GUARD = 0x100
Memory.PAGE_NOCACHE = 0x200
Memory.PAGE_WRITECOMBINE = 0x400
---Changes the protection on a region of committed pages in the virtual address space of the calling process.
---@param addr ffi.cdata*
---@param size integer
---@param newProtect integer
---@return ffi.cdata* oldProtect
function Memory.VirtualProtect(addr, size, newProtect)
local oldProtect = ffi.new('DWORD[1]')
if not ffi.C.VirtualProtect(addr, size, newProtect, oldProtect) then
error(string.format("failed to call VirtualProtect(%s, %s, %s)", addr, size, newProtect))
end
return oldProtect
end
return Memory

View File

@ -0,0 +1,19 @@
-- Copyright (c) 2019-2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
local ffi = require("ffi")
local MonitorStandby = {}
ffi.cdef([[
int SetThreadExecutionState(int esFlags);
]])
-- Reset computer and monitor standby timer.
function MonitorStandby.ResetTimer()
ffi.C.SetThreadExecutionState(3) -- ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED
end
return MonitorStandby

View File

@ -0,0 +1,54 @@
# Noita API wrapper
This wraps the Noita API and exposes it in a more dev friendly way.
Entities and components are returned as objects. All entity and component related functions are now methods of the respective objects.
The library also comes with EmmyLua annotations, so code completion, type information and other hints will work in any IDE or editor that supports this.
(Only tested with VSCode for now)
## State
Working but incomplete.
If something is missing, you need to add it!
It would be nice to have code generation to generate this library from the official files, but meh.
But this would be too complex, as there are a lot of edge cases and stuff that has to be handled in a specific way.
## Usage
1. Copy this library into your mod so you get the following file path: `mods/your-mod/files/libraries/noita-api/README.md`.
2. Add the following at the beginning of your mod's `init.lua`:
```lua
-- Emulate and override some functions and tables to make everything conform more to standard lua.
-- This will make `require` work, even in sandboxes with restricted Noita API.
local libPath = "mods/noita-mapcap/files/libraries/"
dofile(libPath .. "noita-api/compatibility.lua")(libPath)
```
You need to adjust `libPath` to point into your mod's library directory.
The trailing `/` is needed!
After that you can import and use the library like this:
```lua
local EntityAPI = require("noita-api.entity")
local x, y, radius = 10, 10, 100
local entities = EntityAPI.GetInRadius(x, y, radius)
for _, entity in ipairs(entities) do
print(entity:GetName())
local components = entity:GetComponents("VelocityComponent")
for _, component in ipairs(components) do
entity:SetComponentsEnabled(component, false)
end
end
```
To include the whole set of API commands, use:
```lua
local NoitaAPI = require("noita-api")
```

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
local Vec2 = require("noita-api.vec2")
-------------
-- Classes --
-------------
local CameraAPI = {}
------------------------
-- Noita API wrappers --
------------------------
---
---@param strength number
---@param position Vec2|nil -- Defaults to camera position if not set.
function CameraAPI.ScreenShake(strength, position)
if position == nil then
return GameScreenshake(strength)
end
return GameScreenshake(strength, position.x, position.y)
end
---Returns the center position of the viewport in world/virtual coordinates.
---@return Vec2
function CameraAPI.GetPos()
return Vec2(GameGetCameraPos())
end
---Sets the center position of the viewport in world/virtual coordinates.
---@param position Vec2
function CameraAPI.SetPos(position)
return GameSetCameraPos(position.x, position.y)
end
---
---@param isFree boolean
function CameraAPI.SetCameraFree(isFree)
return GameSetCameraFree(isFree)
end
---Returns the camera boundary rectangle in world/virtual coordinates.
---This may not be 100% pixel perfect with regards to what you see on the screen.
---@return Vec2 topLeft
---@return Vec2 bottomRight
function CameraAPI.Bounds()
local x, y, w, h = GameGetCameraBounds()
return Vec2(x, y), Vec2(x + w, y + h)
end
return CameraAPI

View File

@ -0,0 +1,153 @@
-- Copyright (c) 2019-2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Some code to make Noita's lua conform more to standard lua.
-- Stupid way to prevent this code from being called more than once per sandbox.
-- Calling this lua file with dofile_once would still cause the setup function to be called multiple times.
if _NoitaAPICompatibilityWrapperGuard_ then return function(dummy) end end
_NoitaAPICompatibilityWrapperGuard_ = true
local oldPrint = print
-- Emulated print function that behaves more like the standard lua one.
function print(...)
local n = select("#", ...)
--for i, v in ipairs(arg) do
local stringArgs = {}
for i = 1, n do
table.insert(stringArgs, tostring(select(i, ...)))
end
oldPrint(unpack(stringArgs))
end
-- Package doesn't exist when the Lua API is restricted.
-- Therefore we create it here and apply some default values.
package = package or {}
package.path = package.path or "./?.lua" -- Allow paths relative to the working directory.
package.preload = package.preload or {}
package.loaded = package.loaded or {
_G = _G,
bit = bit,
coroutine = coroutine,
debug = debug,
math = math,
package = package,
string = string,
table = table,
--io = io,
--jit = jit,
--os = os,
}
local oldDofile = dofile
---Emulated dofile to execute a lua script from disk and circumvent any caching.
---Noita for some reason caches script files (Or loads them into its virtual filesystem)(Or caches compiled bytecode), so reloading script files from disk does not work without this.
---
---This conforms more with standard lua.
---@param path string
---@return any ...
function dofile(path)
local func, err = loadfile(path)
if not func then error(err) end
return func()
end
local oldRequire = require
local recursionSet = {}
---Emulated require function in case the Lua API is restricted.
---It's probably good enough for most use cases.
---
---We need to override the default require in any case, as only dofile and loadfile can access stuff in the virtual filesystem.
---@param modName string
---@return any ...
function require(modName)
-- Check if package was already loaded, return previous result.
if package.loaded[modName] ~= nil then return package.loaded[modName] end
if recursionSet[modName] then
recursionSet = {}
error(string.format("Cyclic dependency with module %q", modName))
end
recursionSet[modName] = true
local notFoundStr = ""
-- Check if there is an entry in the preload table.
local preloadFunc = package.preload[modName]
if preloadFunc then
local res = preloadFunc(modName)
if res == nil then res = true end
package.loaded[modName] = res
recursionSet[modName] = nil
return res
else
notFoundStr = notFoundStr .. string.format("\tno field package.preload['%s']\n", modName)
end
-- Load and execute scripts.
-- Iterate over all package.path entries.
for pathEntry in string.gmatch(package.path, "[^;]+") do
local modPath = string.gsub(modName, "%.", "/") -- Replace "." with file path delimiter.
local filePath = string.gsub(pathEntry, "?", modPath, 1) -- Insert modPath into "?" placeholder.
local fixedPath = string.gsub(filePath, "^%.[\\/]", "") -- Need to remove "./" or ".\" at the beginning, as Noita allows only "data" and "mods".
if fixedPath:sub(1, 4) == "data" or fixedPath:sub(1, 4) == "mods" then -- Ignore everything other than data and mod root path elements. It's not perfect, but this is just there to prevent console spam.
local func, err = loadfile(fixedPath)
if func then
local state, res = pcall(func)
if not state then
recursionSet = {}
error(res)
end
if res == nil then res = true end
package.loaded[modName] = res
recursionSet[modName] = nil
return res
elseif err and err:sub(1, 45) == "Error loading lua script - file doesn't exist" then -- I hate to do that.
notFoundStr = notFoundStr .. string.format("\tno file '%s'\n", filePath)
else
recursionSet = {}
error(err)
end
else
notFoundStr = notFoundStr .. string.format("\tnot allowed '%s'\n", filePath)
end
end
-- Fall back to the original require, if it exists.
if oldRequire then
local ok, res = pcall(oldRequire, modName)
if ok then
recursionSet[modName] = nil
return res
else
notFoundStr = notFoundStr .. string.format("\toriginal require:%s", res)
end
end
recursionSet = {}
error(string.format("module %q not found:\n%s", modName, notFoundStr))
end
---Set up some stuff so `require` works as expected.
---@param libPath any -- Path to the libraries directory of this mod.
local function setup(libPath)
-- Add the library directory of the mod as base for any `require` lookups.
package.path = package.path:gsub(";$", "") -- Get rid of any trailing semicolon.
package.path = package.path .. ";./" .. libPath .. "?.lua"
package.path = package.path .. ";./" .. libPath .. "?/init.lua"
-- Add the library directory of Noita itself.
package.path = package.path .. ";./data/scripts/lib/?.lua" -- TODO: Get rid of Noita's lib path, create replacement libs for stuff in there
end
return setup

View File

@ -0,0 +1,201 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
local JSON = require("noita-api.json")
-------------
-- Classes --
-------------
local ComponentAPI = {}
---@class NoitaComponent
---@field ID integer -- Noita component ID.
local NoitaComponent = {}
NoitaComponent.__index = NoitaComponent
ComponentAPI.MetaTable = NoitaComponent
---Wraps the given component ID and returns a Noita component object.
---@param id number|nil
---@return NoitaComponent|nil
function ComponentAPI.Wrap(id)
if id == nil or type(id) ~= "number" then return nil end
return setmetatable({ ID = id }, NoitaComponent)
end
------------------------
-- Noita API wrappers --
------------------------
---
---@param tag string
function NoitaComponent:AddTag(tag)
return ComponentAddTag(self.ID, tag)
end
---
---@param tag string
function NoitaComponent:RemoveTag(tag)
return ComponentRemoveTag(self.ID, tag)
end
---
---@param tag string
---@return boolean
function NoitaComponent:HasTag(tag)
return ComponentHasTag(self.ID, tag)
end
---Returns one or many values matching the type or subtypes of the requested field.
---Reports error and returns nil if the field type is not supported or field was not found.
---@param fieldName string
---@return any|nil
function NoitaComponent:GetValue(fieldName)
return ComponentGetValue2(self.ID, fieldName) -- TODO: Rework Noita API to handle vectors, and return a vector instead of some shitty multi value result
end
---Sets the value of a field. Value(s) should have a type matching the field type.
---Reports error if the values weren't given in correct type, the field type is not supported, or the component does not exist.
---@param fieldName string
---@param ... any|nil -- Vectors use one argument per dimension.
function NoitaComponent:SetValue(fieldName, ...)
return ComponentSetValue2(self.ID, fieldName, ...) -- TODO: Rework Noita API to handle vectors, and use a vector instead of shitty multi value arguments
end
---Returns one or many values matching the type or subtypes of the requested field in a component sub-object.
---Reports error and returns nil if the field type is not supported or 'object_name' is not a metaobject.
---
---Reporting errors means that it spams the stdout with messages, instead of using the lua error handling. Thanks Nolla.
---@param objectName string
---@param fieldName string
---@return any|nil
function NoitaComponent:ObjectGetValue(objectName, fieldName)
return ComponentObjectGetValue2(self.ID, objectName, fieldName) -- TODO: Rework Noita API to handle vectors, and return a vector instead of some shitty multi value result
end
---Sets the value of a field in a component sub-object. Value(s) should have a type matching the field type.
---Reports error if the values weren't given in correct type, the field type is not supported or 'object_name' is not a metaobject.
---@param objectName string
---@param fieldName string
---@param ... any|nil -- Vectors use one argument per dimension.
function NoitaComponent:ObjectSetValue(objectName, fieldName, ...)
return ComponentObjectSetValue2(self.ID, objectName, fieldName, ...) -- TODO: Rework Noita API to handle vectors, and use a vector instead of shitty multi value arguments
end
---
---@param arrayMemberName string
---@param typeStoredInVector "int"|"float"|"string"
---@return number
function NoitaComponent:GetVectorSize(arrayMemberName, typeStoredInVector)
return ComponentGetVectorSize(self.ID, arrayMemberName, typeStoredInVector)
end
---
---@param arrayName string
---@param typeStoredInVector "int"|"float"|"string"
---@param index number
---@return number|number|string|nil
function NoitaComponent:GetVectorValue(arrayName, typeStoredInVector, index)
return ComponentGetVectorValue(self.ID, arrayName, typeStoredInVector, index)
end
---
---@param arrayName string
---@param typeStoredInVector "int"|"float"|"string"
---@return number[]|number|string|nil
function NoitaComponent:GetVector(arrayName, typeStoredInVector)
return ComponentGetVector(self.ID, arrayName, typeStoredInVector)
end
---Returns true if the given component exists and is enabled, else false.
---@return boolean
function NoitaComponent:GetIsEnabled()
return ComponentGetIsEnabled(self.ID)
end
---Returns a string-indexed table of string.
---@return table<string, string>|nil
function NoitaComponent:GetMembers()
return ComponentGetMembers(self.ID)
end
---Returns a string-indexed table of string or nil.
---@param objectName string
---@return table<string, string>|nil
function NoitaComponent:ObjectGetMembers(objectName)
return ComponentObjectGetMembers(self.ID, objectName)
end
---
---@return string string
function NoitaComponent:GetTypeName()
return ComponentGetTypeName(self.ID)
end
-- TODO: Add missing Noita API methods and functions.
---
---@return NoitaComponent|nil
function ComponentAPI.GetUpdatedComponent()
return ComponentAPI.Wrap(GetUpdatedComponentID())
end
-- TODO: Add missing Noita API methods and functions.
-------------------------
-- JSON Implementation --
-------------------------
---Returns a new table with all arguments stored into keys `1`, `2`, etc. and with a field `"n"` with the total number of arguments.
---@param ... any
---@return table
local function pack(...)
local t = {...}
t.n = select("#", ...)
return t
end
-- Set of component keys that would return an "invalid type" error when called with ComponentGetValue2().
-- This is more or less to get around console error spam that otherwise can't be prevented when iterating over component members.
-- Only used inside the JSON marshaler, until there is a better solution.
local componentValueKeysWithInvalidType = {}
---MarshalJSON implements the JSON marshaler interface.
---@return string
function NoitaComponent:MarshalJSON()
-- Get list of members, but with correct type (instead of string values).
local membersTable = self:GetMembers()
local members = {}
if membersTable then
for k, v in pairs(membersTable) do
if not componentValueKeysWithInvalidType[k] then
local packedResult = pack(self:GetValue(k)) -- Try to get value with correct type. Assuming nil is an error, but this is not always the case... meh.
if packedResult.n == 0 then
members[k] = nil -- Write no result as nil. Basically do nothing.
elseif packedResult.n == 1 then
members[k] = packedResult[1] -- Write single value result as single value.
else
packedResult.n = nil -- Discard n field, otherwise this is not a pure array.
members[k] = packedResult -- Write multi value result as array.
end
end
if members[k] == nil then
componentValueKeysWithInvalidType[k] = true
--members[k] = v -- Fall back to string value of self:GetMembers().
end
end
end
local resultObject = {
typeName = self:GetTypeName(),
members = members,
--objectMembers = component:ObjectGetMembers
}
return JSON.Marshal(resultObject)
end
return ComponentAPI

View File

@ -0,0 +1,73 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
local Vec2 = require("noita-api.vec2")
-------------
-- Classes --
-------------
local DebugAPI = {}
------------------------
-- Noita API wrappers --
------------------------
---Returns the mouse cursor position in world coordinates.
---@return Vec2
function DebugAPI.GetMouseWorld()
return Vec2(DEBUG_GetMouseWorld())
end
---Draws a mark in the world at the given position.
---@param pos Vec2 -- In world coordinates.
---@param message string|nil -- Defaults to "".
---@param r number|nil -- Color's red amount in the range [0, 1]. Defaults to 1.
---@param g number|nil -- Color's green amount in the range [0, 1]. Defaults to 0.
---@param b number|nil -- Color's blue amount in the range [0, 1]. Defaults to 0.
function DebugAPI.Mark(pos, message, r, g, b)
message, r, g, b = message or "", r or 1, g or 0, b or 0
return DEBUG_MARK(pos.x, pos.y, message, r, g, b)
end
---Returns true if this is a beta version of the game.
---
---Can return nil it seems.
---@return boolean|nil
function DebugAPI.IsBetaBuild()
return GameIsBetaBuild()
end
---Returns true if this is the dev version of the game (`noita_dev.exe`).
---@return boolean
function DebugAPI.IsDevBuild()
return DebugGetIsDevBuild()
end
---Enables the trailer mode and some other things:
---
--- - Disables in-game GUI.
--- - Opens fog of war everywhere (Not the same as disabling it completely).
--- - Enables `mTrailerMode`, whatever that does.
---
---No idea how to disable it, beside pressing F12 in dev build.
function DebugAPI.EnableTrailerMode()
return DebugEnableTrailerMode()
end
---
---@return boolean
function DebugAPI.IsTrailerModeEnabled()
return GameGetIsTrailerModeEnabled()
end
---
---@param pos Vec2 -- In world coordinates.
---@return string
function DebugAPI.BiomeMapGetFilename(pos)
return DebugBiomeMapGetFilename(pos.x, pos.y)
end
return DebugAPI

View File

@ -0,0 +1,444 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
local ComponentAPI = require("noita-api.component")
local JSON = require("noita-api.json")
-------------
-- Classes --
-------------
local EntityAPI = {}
---@class NoitaEntity
---@field ID integer -- Noita entity ID.
local NoitaEntity = {}
NoitaEntity.__index = NoitaEntity
EntityAPI.MetaTable = NoitaEntity
---Wraps the given entity ID and returns a Noita entity object.
---@param id number|nil
---@return NoitaEntity|nil
function EntityAPI.Wrap(id)
if id == nil or type(id) ~= "number" then return nil end
return setmetatable({ ID = id }, NoitaEntity)
end
------------------------
-- Noita API wrappers --
------------------------
---
---@param filename string
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- Y coordinate in world (virtual) pixels.
---@return NoitaEntity|nil
function EntityAPI.Load(filename, posX, posY) -- TODO: Change to use Vec2 object
return EntityAPI.Wrap(EntityLoad(filename, posX, posY))
end
---
---@param filename string
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- Y coordinate in world (virtual) pixels.
---@return NoitaEntity|nil
function EntityAPI.LoadEndGameItem(filename, posX, posY) -- TODO: Change to use Vec2 object
return EntityAPI.Wrap(EntityLoadEndGameItem(filename, posX, posY))
end
---
---@param filename string
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- Y coordinate in world (virtual) pixels.
function EntityAPI.LoadCameraBound(filename, posX, posY) -- TODO: Change to use Vec2 object
return EntityLoadCameraBound(filename, posX, posY)
end
---Creates a new entity from the given XML file, and attaches it to entity.
---This will not load tags and other stuff, it seems.
---@param filename string
---@param entity NoitaEntity
function EntityAPI.LoadToEntity(filename, entity)
return EntityLoadToEntity(filename, entity.ID)
end
---
---Note: works only in dev builds.
---@param filename string
function NoitaEntity:Save(filename)
return EntitySave(self.ID, filename)
end
---
---@param name string
---@return NoitaEntity|nil
function EntityAPI.CreateNew(name)
return EntityAPI.Wrap(EntityCreateNew(name))
end
---
function NoitaEntity:Kill()
return EntityKill(self.ID)
end
---
function NoitaEntity:IsAlive()
return EntityGetIsAlive(self.ID)
end
---
---@param componentTypeName string
---@param tableOfComponentValues string[]
---@return NoitaComponent|nil
function NoitaEntity:AddComponent(componentTypeName, tableOfComponentValues)
local componentID = EntityAddComponent(self.ID, componentTypeName, tableOfComponentValues)
return ComponentAPI.Wrap(componentID)
end
---
---@param component NoitaComponent
function NoitaEntity:RemoveComponent(component)
return EntityRemoveComponent(self.ID, component.ID)
end
---Returns a table of with all components of this entity.
---@return NoitaComponent[]
function NoitaEntity:GetAllComponents()
local componentIDs = EntityGetAllComponents(self.ID) or {}
local result = {}
for _, componentID in ipairs(componentIDs) do
table.insert(result, ComponentAPI.Wrap(componentID))
end
return result
end
---Returns a table of components filtered by the given parameters.
---@param componentTypeName string
---@param tag string|nil
---@return NoitaComponent[]
function NoitaEntity:GetComponents(componentTypeName, tag)
local componentIDs
if tag ~= nil then
componentIDs = EntityGetComponent(self.ID, componentTypeName, tag) or {}
else
componentIDs = EntityGetComponent(self.ID, componentTypeName) or {}
end
local result = {}
for _, componentID in ipairs(componentIDs) do
table.insert(result, ComponentAPI.Wrap(componentID))
end
return result
end
---Returns the first component of this entity that fits the given parameters.
---@param componentTypeName string
---@param tag string|nil
---@return NoitaComponent|nil
function NoitaEntity:GetFirstComponent(componentTypeName, tag)
local componentID
if tag ~= nil then
componentID = EntityGetFirstComponent(self.ID, componentTypeName, tag)
else
componentID = EntityGetFirstComponent(self.ID, componentTypeName)
end
return ComponentAPI.Wrap(componentID)
end
---Sets the transform of the entity.
---@param x number
---@param y number
---@param rotation number
---@param scaleX number
---@param scaleY number
function NoitaEntity:SetTransform(x, y, rotation, scaleX, scaleY) -- TODO: Change to use Vec2 object
return EntitySetTransform(self.ID, x, y, rotation, scaleX, scaleY)
end
---Sets the transform and tries to immediately refresh components that calculate values based on an entity's transform.
---@param x number
---@param y number
---@param rotation number
---@param scaleX number
---@param scaleY number
function NoitaEntity:SetAndApplyTransform(x, y, rotation, scaleX, scaleY) -- TODO: Change to use Vec2 object
return EntityApplyTransform(self.ID, x, y, rotation, scaleX, scaleY)
end
---Returns the transformation of the entity.
---@return number x, number y, number rotation, number scaleX, number scaleY
function NoitaEntity:GetTransform() -- TODO: Change to use Vec2 object
return EntityGetTransform(self.ID)
end
---
---@param child NoitaEntity
function NoitaEntity:AddChild(child)
return EntityAddChild(self.ID, child.ID)
end
---
---@return NoitaEntity[]
function NoitaEntity:GetAllChildren()
local entityIDs = EntityGetAllChildren(self.ID) or {}
local result = {}
for _, entityID in ipairs(entityIDs) do
table.insert(result, EntityAPI.Wrap(entityID))
end
return result
end
---
---@return NoitaEntity|nil
function NoitaEntity:GetParent()
return EntityAPI.Wrap(EntityGetParent(self.ID))
end
---Returns the given entity if it has no parent, otherwise walks up the parent hierarchy to the topmost parent and returns it.
---@return NoitaEntity|nil
function NoitaEntity:GetRootEntity()
return EntityAPI.Wrap(EntityGetRootEntity(self.ID))
end
---
function NoitaEntity:RemoveFromParent()
return EntityRemoveFromParent(self.ID)
end
---
---@param tag string
---@param enabled boolean
function NoitaEntity:SetComponentsWithTagEnabled(tag, enabled)
return EntitySetComponentsWithTagEnabled(self.ID, tag, enabled)
end
---
---@param component NoitaComponent
---@param enabled boolean
function NoitaEntity:SetComponentsEnabled(component, enabled)
return EntitySetComponentIsEnabled(self.ID, component.ID, enabled)
end
---
---@return string
function NoitaEntity:GetName()
return EntityGetName(self.ID)
end
---
---@param name string
function NoitaEntity:SetName(name)
return EntitySetName(self.ID, name)
end
---Returns an array of all the entity's tags.
---@return string[]
function NoitaEntity:GetTags()
---@type string
local tagsString = EntityGetTags(self.ID) or ""
local result = {}
for tag in tagsString:gmatch('([^,]+)') do
table.insert(result, tag)
end
return result
end
---Returns all entities with 'tag'.
---@param tag string
---@return NoitaEntity[]
function EntityAPI.GetWithTag(tag)
local entityIDs = EntityGetWithTag(tag) or {}
local result = {}
for _, entityID in ipairs(entityIDs) do
table.insert(result, EntityAPI.Wrap(entityID))
end
return result
end
---Returns all entities in 'radius' distance from 'x','y'.
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- X coordinate in world (virtual) pixels.
---@param radius number -- Radius in world (virtual) pixels.
---@return NoitaEntity[]
function EntityAPI.GetInRadius(posX, posY, radius) -- TODO: Change to use Vec2 object
local entityIDs = EntityGetInRadius(posX, posY, radius) or {}
local result = {}
for _, entityID in ipairs(entityIDs) do
table.insert(result, EntityAPI.Wrap(entityID))
end
return result
end
---Returns all entities in 'radius' distance from 'x','y' that have the given tag.
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- X coordinate in world (virtual) pixels.
---@param radius number -- Radius in world (virtual) pixels.
---@param tag string
---@return NoitaEntity[]
function EntityAPI.GetInRadiusWithTag(posX, posY, radius, tag) -- TODO: Change to use Vec2 object
local entityIDs = EntityGetInRadiusWithTag(posX, posY, radius, tag) or {}
local result = {}
for _, entityID in ipairs(entityIDs) do
table.insert(result, EntityAPI.Wrap(entityID))
end
return result
end
---
---@param posX number -- X coordinate in world (virtual) pixels.
---@param posY number -- X coordinate in world (virtual) pixels.
---@return NoitaEntity|nil
function EntityAPI.GetClosest(posX, posY) -- TODO: Change to use Vec2 object
return EntityAPI.Wrap(EntityGetClosest(posX, posY))
end
---
---@param name string
---@return NoitaEntity|nil
function EntityAPI.GetWithName(name)
return EntityAPI.Wrap(EntityGetWithName(name))
end
---
---@param tag string
function NoitaEntity:AddTag(tag)
return EntityAddTag(self.ID, tag)
end
---
---@param tag string
function NoitaEntity:RemoveTag(tag)
return EntityRemoveTag(self.ID, tag)
end
---
---@param tag string
---@return boolean
function NoitaEntity:HasTag(tag)
return EntityHasTag(self.ID, tag)
end
---
---@return string -- example: 'data/entities/items/flute.xml'.
function NoitaEntity:GetFilename()
return EntityGetFilename(self.ID)
end
---Creates a component of type 'component_type_name' and adds it to 'entity_id'.
---'table_of_component_values' should be a string-indexed table, where keys are field names and values are field values of correct type.
---The value setting works like ComponentObjectSetValue2(), with the exception that multi value types are not supported.
---Additional supported values are _tags:comma_separated_string and _enabled:bool, which basically work like the those fields work in entity XML files.
---Returns the created component, if creation succeeded, or nil.
---@param componentTypeName string
---@param tableOfComponentValues table<string, any>
---@return NoitaComponent|nil
function NoitaEntity:EntityAddComponent(componentTypeName, tableOfComponentValues)
local componentID = EntityAddComponent2(self.ID, componentTypeName, tableOfComponentValues)
return ComponentAPI.Wrap(componentID)
end
-- TODO: Add missing Noita API methods and functions.
---
---@return NoitaEntity|nil
function EntityAPI.GetUpdatedEntity()
return EntityAPI.Wrap(GetUpdatedEntityID())
end
---
---@return NoitaEntity|nil
function EntityAPI.GetWorldStateEntity()
return EntityAPI.Wrap(GameGetWorldStateEntity())
end
---
---@return NoitaEntity|nil
function EntityAPI.GetPlayerStatsEntity()
return EntityAPI.Wrap(GameGetPlayerStatsEntity())
end
-- TODO: Add missing Noita API methods and functions.
---
function NoitaEntity:RegenItemAction()
return GameRegenItemAction(self.ID)
end
---
function NoitaEntity:RegenItemActionsInContainer()
return GameRegenItemActionsInContainer(self.ID)
end
---
function NoitaEntity:RegenItemActionsInPlayer()
return GameRegenItemActionsInPlayer(self.ID)
end
---
---@param itemEntity NoitaEntity
function NoitaEntity:KillInventoryItem(itemEntity)
return GameKillInventoryItem(self.ID, itemEntity.ID)
end
---
---@param itemEntity NoitaEntity
---@param doPickUpEffects boolean
function NoitaEntity:PickUpInventoryItem(itemEntity, doPickUpEffects)
if doPickUpEffects == nil then doPickUpEffects = true end
return GamePickUpInventoryItem(self.ID, itemEntity.ID, doPickUpEffects)
end
---
function NoitaEntity:DropAllItems()
return GameDropAllItems(self.ID)
end
---
function NoitaEntity:DropPlayerInventoryItems()
return GameDropPlayerInventoryItems(self.ID)
end
---
function NoitaEntity:DestroyInventoryItems()
return GameDestroyInventoryItems(self.ID)
end
-- TODO: Add missing Noita API methods and functions.
---
---@return boolean
function NoitaEntity:IsPlayer()
return IsPlayer(self.ID)
end
---
---@return boolean
function NoitaEntity:IsInvisible()
return IsInvisible(self.ID)
end
-- TODO: Add missing Noita API methods and functions.
-------------------------
-- JSON Implementation --
-------------------------
---MarshalJSON implements the JSON marshaler interface.
---@return string
function NoitaEntity:MarshalJSON()
local result = {
name = self:GetName(),
filename = self:GetFilename(),
tags = self:GetTags(),
children = self:GetAllChildren(),
components = self:GetAllComponents(),
transform = {},
}
result.transform.x, result.transform.y, result.transform.rotation, result.transform.scaleX, result.transform.scaleY = self:GetTransform()
return JSON.Marshal(result)
end
return EntityAPI

View File

@ -0,0 +1,14 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
return {
Camera = require("noita-api.camera"),
Component = require("noita-api.component"),
Debug = require("noita-api.debug"),
Entity = require("noita-api.entity"),
JSON = require("noita-api.json"),
Utils = require("noita-api.utils"),
Vec2 = require("noita-api.vec2"),
}

View File

@ -0,0 +1,178 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Simple library to marshal JSON values. Mainly for Noita.
local lib = {}
---Maps single characters to escaped strings.
---
---Copyright (c) 2020 rxi
---@see [github.com/rxi/json.lua](https://github.com/rxi/json.lua/blob/master/json.lua)
local escapeCharacters = {
["\\"] = "\\",
["\""] = "\"",
["\b"] = "b",
["\f"] = "f",
["\n"] = "n",
["\r"] = "r",
["\t"] = "t",
}
---escapeRune returns the escaped string for a given rune.
---
---Copyright (c) 2020 rxi
---@see [github.com/rxi/json.lua](https://github.com/rxi/json.lua/blob/master/json.lua)
---@param rune string
---@return string
local function escapeCharacter(rune)
return "\\" .. (escapeCharacters[rune] or string.format("u%04x", rune:byte()))
end
---escapeString returns the escaped version of the given string.
---
---Copyright (c) 2020 rxi
---@see [github.com/rxi/json.lua](https://github.com/rxi/json.lua/blob/master/json.lua)
---@param str string
---@return string
local function escapeString(str)
local result, count = str:gsub('[%z\1-\31\\"]', escapeCharacter)
return result
end
---MarshalString returns the JSON representation of a string value.
---@param val string
---@return string
function lib.MarshalString(val)
return string.format("%q", escapeString(val))
end
---MarshalNumber returns the JSON representation of a number value.
---@param val number
---@return string
function lib.MarshalNumber(val)
-- Edge cases, as there is no real solution to this.
-- JSON can't store special IEEE754 values, this is dumb as hell.
if val ~= val then
return "null" -- Alternatively we could output the string "NaN" (With quotes).
elseif val <= -math.huge then
return "-1E+600" -- Just output a stupidly large number.
elseif val >= math.huge then
return "1E+600" -- Just output a stupidly large number.
end
return tostring(val)
end
---MarshalBoolean returns the JSON representation of a boolean value.
---@param val boolean
---@return string
function lib.MarshalBoolean(val)
return tostring(val)
end
---MarshalObject returns the JSON representation of a table object.
---
---This only works with string keys. Number keys will be converted into strings.
---@param val table<string,any>
---@return string
function lib.MarshalObject(val)
local result = "{"
for k, v in pairs(val) do
result = result .. lib.MarshalString(k) .. ": " .. lib.Marshal(v)
-- Append character depending on whether this is the last element or not.
if next(val, k) == nil then
result = result .. "}"
else
result = result .. ", "
end
end
return result
end
---MarshalArray returns the JSON representation of an array object.
---
---@param val table<number,any>
---@param customMarshalFunction function|nil -- Custom function for marshalling the array values.
---@return string
function lib.MarshalArray(val, customMarshalFunction)
local result = "["
-- TODO: Check if the type of all array entries is the same.
local length = #val
for i, v in ipairs(val) do
if customMarshalFunction then
result = result .. customMarshalFunction(v)
else
result = result .. lib.Marshal(v)
end
-- Append character depending on whether this is the last element or not.
if i == length then
result = result .. "]"
else
result = result .. ", "
end
end
return result
end
---Marshal marshals any value into JSON representation.
---@param val any
---@return string
function lib.Marshal(val)
local t = type(val)
if t == "nil" then
return "null"
elseif t == "number" then
return lib.MarshalNumber(val)
elseif t == "string" then
return lib.MarshalString(val)
elseif t == "boolean" then
return lib.MarshalBoolean(val)
elseif t == "table" then
-- Check if object implements the JSON marshaler interface.
if val.MarshalJSON ~= nil and type(val.MarshalJSON) == "function" then
return val:MarshalJSON()
end
-- If not, fall back to array or object handling.
local commonKeyType, commonValueType
for k, v in pairs(val) do
local keyType, valueType = type(k), type(v)
commonKeyType = commonKeyType or keyType
if commonKeyType ~= keyType then
-- Different types detected, abort.
commonKeyType = "mixed"
break
end
commonValueType = commonValueType or valueType
if commonValueType ~= valueType then
-- Different types detected, abort.
commonValueType = "mixed"
break
end
end
-- Decide based on common types.
if commonKeyType == "number" and commonValueType ~= "mixed" then
return lib.MarshalArray(val) -- This will falsely detect sparse integer key maps as arrays. But meh.
elseif commonKeyType == "string" then
return lib.MarshalObject(val) -- This will not detect if there are number keys, which would work with MarshalObject.
elseif commonKeyType == nil and commonValueType == nil then
return "null" -- Fallback in case of empty table. There is no other way than using null, as we don't have type information without table elements.
end
error(string.format("unsupported table type. CommonKeyType = %s. CommonValueType = %s. MetaTable = %s", commonKeyType or "nil", commonValueType or "nil", getmetatable(val) or "nil"))
end
error(string.format("unsupported type %q", t))
end
return lib

View File

@ -0,0 +1,29 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Allows Noita mods to reload themselves every now and then.
-- This helps dramatically with development, as we don't have to restart Noita for every change.
local LiveReload = {}
---Reloads the mod's init file in the given interval in frames.
---For reloading to work correctly, the mod has to be structured in a special way.
---Like the usage of require, namespaces, correct error handling, ...
---
---Just put this into your `OnWorldPreUpdate` or `OnWorldPostUpdate` callback:
---
--- LiveReload:Reload("mods/your-mod/", 60) -- The trailing path separator is needed!
---@param modPath string
---@param interval integer
function LiveReload:Reload(modPath, interval)
interval = interval or 60
self.Counter = (self.Counter or 0) + 1
if self.Counter < interval then return end
self.Counter = nil
dofile(modPath .. "init.lua")
end
return LiveReload

View File

@ -0,0 +1,54 @@
-- Copyright (c) 2019-2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- This contains just some utilities that may be useful to have.
local DebugAPI = require("noita-api.debug")
local Utils = {}
---Returns if the file at filePath exists.
---This only works correctly when API access is not restricted.
---@param filePath string
---@return boolean
function Utils.FileExists(filePath)
local f = io.open(filePath, "r")
if f ~= nil then
io.close(f)
return true
else
return false
end
end
local specialDirectoryDev = {
["save-shared"] = "save_shared/",
["save-stats"] = "save_stats/", -- Assumes that the first save is the currently used one.
["save"] = "save00/" -- Assumes that the first save is the currently used one.
}
local specialDirectory = {
["save-shared"] = "save_shared/",
["save-stats"] = "save00/stats/", -- Assumes that the first save is the currently used one.
["save"] = "save00/" -- Assumes that the first save is the currently used one.
}
---Returns the path to the special directory, or nil in case it couldn't be determined.
---This only works correctly when API access is not restricted.
---@param id "save-shared"|"save-stats"|"save"
---@return string|nil
function Utils.GetSpecialDirectory(id)
if DebugAPI.IsDevBuild() then
-- We are in the dev build.
return "./" .. specialDirectoryDev[id]
else
-- We are in the normal Noita executable.
-- Hacky way to get to LocalLow, there is just no other way to get this path. :/
local pathPrefix = os.getenv('APPDATA'):gsub("[^\\/]+$", "") .. "LocalLow/Nolla_Games_Noita/"
return pathPrefix .. specialDirectory[id]
end
end
return Utils

View File

@ -0,0 +1,330 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Just 2D vector stuff. Mainly for Noita.
-- State: Some stuff is untested.
---Metatable of the Vec2 object that is returned by this lib.
---This will only contain a __call field pointing to the constructor.
---@class Vec2Meta
local libMetaTable = {}
---@class Vec2
---@field [1] number
---@field [2] number
---@field x number
---@field y number
local Vec2 = setmetatable({}, libMetaTable)
-----------------
-- Constructor --
-----------------
---Creates a new vector.
---
--- Vec2() -- Returns a vector with zeroed coordinates.
--- Vec2(v) -- Returns a copy of the given Vec2 object.
--- Vec2("1.2, 3.4") -- Returns a vector with x = 1.2 and y = 3.4.
--- Vec2(1.2, 3.4) -- Returns a vector with x = 1.2 and y = 3.4.
---@param ... any
---@return Vec2
function libMetaTable:__call(...)
local n = select("#", ...)
if n == 0 then
return setmetatable({ 0, 0 }, Vec2) -- Zero initialized vector.
elseif n == 1 then
local param = select(1, ...)
if type(param) == "string" then
local vector = {}
for field in string.gmatch(param, "[^,%s]+") do
table.insert(vector, tonumber(field))
end
assert(#vector == 2, string.format("parsed vector contains an invalid number of fields: %d, expected %d", #vector, 2))
return setmetatable(vector, Vec2) -- Vector initialized with the given coordinates.
elseif getmetatable(param) == Vec2 then
return Vec2(param[1], param[2])
end
error(string.format("unsupported argument type %q", type(param)))
elseif n == 2 then
assert(type(select(1, ...)) == "number", string.format("first argument has type %q, expects %q", type(select(1, ...)), "number"))
assert(type(select(2, ...)) == "number", string.format("first argument has type %q, expects %q", type(select(2, ...)), "number"))
return setmetatable({ ... }, Vec2) -- Vector initialized with the given coordinates.
end
error(string.format("called Vec2 constructor with %d argument(s)", n))
end
-----------------
-- Metamethods --
-----------------
---Handle special fields, like x and y.
---@param key any
---@return any
function Vec2:__index(key)
if type(key) == "number" then return rawget(self, key) end
if key == "x" then return rawget(self, 1) end
if key == "y" then return rawget(self, 2) end
return rawget(Vec2, key)
end
---Handle special fields, like x and y.
---@param key any
function Vec2:__newindex(key, value)
if type(key) == "number" then return rawset(self, key, value) end
if key == "x" then rawset(self, 1, value) end
if key == "y" then rawset(self, 2, value) end
-- There should no way to manipulate any other keys of the object or its metatable.
end
---Returns a string representation of this vector.
---This supports a round-trip via Vec2(tostring(v)) without loss of information.
---@return string
function Vec2:__tostring()
return string.format("%.16g, %.16g", self[1], self[2])
end
----------------------------
-- Mathematic metamethods --
----------------------------
---Returns a new vector that is the sum v1 + v2.
---
---This will not mutate any vector.
---@param v1 Vec2
---@param v2 Vec2
---@return Vec2
function Vec2.__add(v1, v2)
assert(getmetatable(v1) == Vec2 and getmetatable(v2) == Vec2, "wrong argument types. Expected two Vec2 objects")
return Vec2(v1[1] + v2[1], v1[2] + v2[2])
end
---Returns a new vector that is the difference v1 - v2.
---
---This will not mutate any vector.
---@param v1 Vec2
---@param v2 Vec2
---@return Vec2
function Vec2.__sub(v1, v2)
assert(getmetatable(v1) == Vec2 and getmetatable(v2) == Vec2, "wrong argument types. Expected two Vec2 objects")
return Vec2(v1[1] - v2[1], v1[2] - v2[2])
end
---Returns a new vector that is the multiplication of a vector with a scalar.
---
---This will not mutate any value.
---@param a number|Vec2
---@param b number|Vec2
---@return Vec2
function Vec2.__mul(a, b)
if type(a) == "number" and getmetatable(b) == Vec2 then
return Vec2(b[1] * a, b[2] * a)
elseif getmetatable(a) == Vec2 and type(b) == "number" then
return Vec2(a[1] * b, a[2] * b)
end
error(string.format("invalid combination of argument types for multiplication: %q and %q", type(a), type(b)))
end
---Returns a new vector that is the division of a vector by a scalar.
---
---This will not mutate any value.
---@param v Vec2
---@param s number
---@return Vec2
function Vec2.__div(v, s)
if getmetatable(v) == Vec2 and type(s) == "number" then
return Vec2(v[1] / s, v[2] / s)
end
error(string.format("invalid combination of argument types for division: %q and %q", type(v), type(s)))
end
---Returns the negated vector.
---
---This will not mutate any value.
---@return Vec2
function Vec2:__unm()
return Vec2(-self[1], -self[2])
end
---Returns whether the two vectors are equal.
---Will return false if one of the values is not a vector.
---@param v1 Vec2
---@param v2 Vec2
---@return boolean
function Vec2.__eq(v1, v2)
if getmetatable(v1) ~= Vec2 or getmetatable(v2) ~= Vec2 then return false end
return v1[1] == v2[1] and v1[2] == v2[2]
end
-------------
-- Methods --
-------------
---Adds v to the vector.
---
---This mutates self.
---@param v Vec2
function Vec2:Add(v)
assert(getmetatable(v) == Vec2, string.format("wrong argument type %q, expected Vec2 object", type(v)))
self[1], self[2] = self[1] + v[1], self[2] + v[2]
end
---Subtracts v from the vector.
---
---This mutates self.
---@param v Vec2
function Vec2:Sub(v)
assert(getmetatable(v) == Vec2, string.format("wrong argument type %q, expected Vec2 object", type(v)))
self[1], self[2] = self[1] - v[1], self[2] - v[2]
end
---Multiplies self with the given scalar.
---
---This mutates self.
---@param s number
function Vec2:Mul(s)
assert(type(s) == "number", string.format("wrong argument type %q, expected number", type(s)))
self[1], self[2] = self[1] * s, self[2] * s
end
---Divides self by the given scalar.
---
---This mutates self.
---@param s number
function Vec2:Div(s)
assert(type(s) == "number", string.format("wrong argument type %q, expected number", type(s)))
self[1], self[2] = self[1] / s, self[2] / s
end
---Returns a copy of self.
---@return Vec2
function Vec2:Copy()
return Vec2(self)
end
---Returns the vector fields as parameters.
---@return number
---@return number
function Vec2:Unpack()
return self[1], self[2]
end
---Sets the vector fields to the given coordinates.
---@param x number
---@param y number
function Vec2:Set(x, y)
assert(type(x) == "number", string.format("wrong argument type %q, expected number", type(x)))
assert(type(y) == "number", string.format("wrong argument type %q, expected number", type(y)))
self[1], self[2] = x, y
end
---Returns the squared length of the vector.
---@return number
function Vec2:LengthSqr()
return self[1] ^ 2 + self[2] ^ 2
end
---Returns the length of the vector.
---@return number
function Vec2:Length()
return math.sqrt(self:LengthSqr())
end
---Returns the squared distance of self to the given vector.
---@param v Vec2
---@return number
function Vec2:DistanceSqr(v)
return (v - self):LengthSqr()
end
---Returns the distance of self to the given vector.
---@param v Vec2
---@return number
function Vec2:Distance(v)
return (v - self):Length()
end
---Sets the length of the vector to 1.
---
---This mutates self.
function Vec2:Normalize()
local len = self:Length()
self:Div(len)
end
---Returns a copy of the vector with its length set to 1.
---@return Vec2
function Vec2:Normalized()
local len = self:Length()
return self / len
end
---Compares this vector to the given one.
---@param v Vec2
---@param tolerance number -- Tolerance per field.
---@return boolean
function Vec2:EqualTo(v, tolerance)
if math.abs(v[1] - self[1]) > tolerance then return false end
if math.abs(v[2] - self[2]) > tolerance then return false end
return true
end
---Round returns v rounded by the given method.
---@param x number
---@param method "nearest"|"floor"|"ceil"|"to-zero"|"away-zero"|nil -- Defaults to "nearest".
---@return integer
local function round(x, method)
method = method or "nearest"
if method == "nearest" then
return math.floor(x + 0.5)
elseif method == "floor" then
return math.floor(x)
elseif method == "ceil" then
return math.ceil(x)
elseif method == "to-zero" then
if x >= 0 then
return math.floor(x)
else
return math.ceil(x)
end
elseif method == "away-zero" then
if x >= 0 then
return math.ceil(x)
else
return math.floor(x)
end
end
error(string.format("invalid rounding method %q", method))
end
---Round rounds all vector fields individually by the given rounding method.
---@param method "nearest"|"floor"|"ceil"|"to-zero"|"away-zero"|nil -- Defaults to "nearest".
function Vec2:Round(method)
self[1], self[2] = round(self[1], method), round(self[2], method)
end
---Round rounds all vector fields individually by the given rounding method.
---@param method "nearest"|"floor"|"ceil"|"to-zero"|"away-zero"|nil -- Defaults to "nearest".
---@return Vec2
function Vec2:Rounded(method)
return Vec2(round(self[1], method), round(self[2], method))
end
-------------------------
-- JSON Implementation --
-------------------------
---MarshalJSON implements the JSON marshaler interface.
---@return string
function Vec2:MarshalJSON()
return string.format("[%.16g, %.16g]", self[1], self[2]) -- Encode as JSON array. -- TODO: Handle NaN, +Inf, -Inf, ... correctly
end
return Vec2

View File

@ -0,0 +1,118 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- A simple library to run/control processes. Specifically made for the Noita map capture addon.
-- This allows only one process to be run at a time in a given context.
-- No idea if this library has much use outside of this mod.
if not async then
require("coroutines") -- Loads Noita's coroutines library from `data/scripts/lib/coroutines.lua`.
end
-------------
-- Classes --
-------------
local ProcessRunner = {}
---@class ProcessRunnerCtx
---@field running boolean|nil
---@field stopping boolean|nil
---@field state table|nil
local Context = {}
Context.__index = Context
-----------------
-- Constructor --
-----------------
---Returns a new process runner context.
---@return ProcessRunnerCtx
function ProcessRunner.New()
return setmetatable({}, Context)
end
-------------
-- Methods --
-------------
---Returns whether some process is running.
---@return boolean
function Context:IsRunning()
return self.running or false
end
---Returns whether the process needs to stop as soon as possible.
---@return boolean
function Context:IsStopping()
return self.stopping or false
end
---Returns a table with information about the current state of the process.
---Will return nil if there is no process.
---@return table|nil -- Custom to the process.
function Context:GetState()
return self.state
end
---Tells the currently running process to stop.
function Context:Stop()
self.stopping = true
end
---Starts a process with the three given callback functions.
---This will just call the tree callbacks in order.
---Everything is called from inside a coroutine, so you can use yield.
---
---There can only be ever one process at a time.
---If there is already a process running, this will just do nothing.
---@param initFunc fun(ctx:ProcessRunnerCtx)|nil -- Called first.
---@param doFunc fun(ctx:ProcessRunnerCtx)|nil -- Called after `initFunc` has been run.
---@param endFunc fun(ctx:ProcessRunnerCtx)|nil -- Called after `doFunc` has been run.
---@param errFunc fun(err:string, scope:"init"|"do"|"end") -- Called on any error.
---@return boolean -- True if process was started successfully.
function Context:Run(initFunc, doFunc, endFunc, errFunc)
if self.running then return false end
self.running, self.stopping, self.state = true, false, {}
async(function()
-- Init function.
if initFunc then
local ok, err = pcall(initFunc, self)
if not ok then
-- Error happened, abort.
if endFunc then pcall(endFunc, self) end
errFunc(err, "init")
self.running, self.stopping = false, false
return
end
end
-- Do function.
if doFunc then
local ok, err = pcall(doFunc, self)
if not ok then
-- Error happened, abort.
errFunc(err, "do")
end
end
-- End function.
if endFunc then
local ok, err = pcall(endFunc, self)
if not ok then
-- Error happened, abort.
errFunc(err, "end")
end
end
self.running, self.stopping, self.state = false, false, nil
end)
return true
end
return ProcessRunner

View File

@ -0,0 +1,63 @@
-- Copyright (c) 2019-2023 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
local Vec2 = require("noita-api.vec2")
local ffi = require("ffi")
local ScreenCap = {}
local status, res = pcall(ffi.load, "mods/noita-mapcap/bin/capture-b/capture")
if not status then
print(string.format("Error loading capture lib: %s", res))
return
end
ffi.cdef([[
typedef long LONG;
typedef struct {
LONG left;
LONG top;
LONG right;
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);
]])
---Takes a screenshot of the client area of this process' active window.
---@param topLeft Vec2 -- Screenshot rectangle's top left coordinate relative to the window's client area in screen pixels.
---@param bottomRight Vec2 -- Screenshot rectangle's bottom right coordinate relative to the window's client area in screen pixels. The pixel is not included in the screenshot area.
---@param topLeftOutput Vec2 -- The corresponding scaled world coordinates of the screenshot rectangles' top left corner.
---@param finalDimensions Vec2|nil -- The final dimensions that the screenshot will be resized to. If set to zero, no resize will happen.
---@return boolean
function ScreenCap.Capture(topLeft, bottomRight, topLeftOutput, finalDimensions)
finalDimensions = finalDimensions or Vec2(0, 0)
local rect = ffi.new("RECT", { math.floor(topLeft.x + 0.5), math.floor(topLeft.y + 0.5), math.floor(bottomRight.x + 0.5), math.floor(bottomRight.y + 0.5) })
return res.Capture(rect, math.floor(topLeftOutput.x + 0.5), math.floor(topLeftOutput.y + 0.5), math.floor(finalDimensions.x + 0.5), math.floor(finalDimensions.y + 0.5))
end
---Returns the client rectangle of the "Main" window of this process in screen coordinates.
---@return Vec2|nil topLeft
---@return Vec2|nil bottomRight
function ScreenCap.GetRect()
local rect = ffi.new("RECT")
if not res.GetRect(rect) then
return nil, nil
end
return Vec2(rect.left, rect.top), Vec2(rect.right, rect.bottom)
end
return ScreenCap

View File

@ -1,8 +0,0 @@
<Entity>
<LuaComponent script_source_file="mods/noita-mapcap/files/init.lua"
enable_coroutines="1"
execute_on_added="1"
execute_every_n_frame="-1"
execute_times="1">
</LuaComponent>
</Entity>

View File

@ -0,0 +1,4 @@
<MagicNumbers
VIRTUAL_RESOLUTION_X="1024"
VIRTUAL_RESOLUTION_Y="1024"
></MagicNumbers>

View File

@ -0,0 +1,4 @@
<MagicNumbers
VIRTUAL_RESOLUTION_X="512"
VIRTUAL_RESOLUTION_Y="512"
></MagicNumbers>

View File

@ -0,0 +1,4 @@
<MagicNumbers
VIRTUAL_RESOLUTION_X="64"
VIRTUAL_RESOLUTION_Y="64"
></MagicNumbers>

View File

@ -0,0 +1,3 @@
<MagicNumbers
DEBUG_FREE_CAMERA_SPEED="10"
></MagicNumbers>

View File

@ -0,0 +1,16 @@
<MagicNumbers
DRAW_PARALLAX_BACKGROUND="0"
DEBUG_NO_LOGO_SPLASHES="1"
DEBUG_PAUSE_GRID_UPDATE="1"
DEBUG_PAUSE_BOX2D="1"
DEBUG_DISABLE_POSTFX_DITHERING="1"
DEBUG_NO_PAUSE_ON_WINDOW_FOCUS_LOST="1"
UI_IMPORTANT_MESSAGE_POS_Y="2000"
UI_STAT_BAR_ICON_OFFSET_Y="2000"
UI_STAT_BAR_TEXT_OFFSET_X="2000"
UI_STAT_BAR_TEXT_OFFSET_Y="2000"
UI_QUICKBAR_OFFSET_X="2000"
UI_QUICKBAR_OFFSET_Y="2000"
UI_BARS_POS_X="2000"
UI_BARS_POS_Y="2000"
></MagicNumbers>

View File

@ -0,0 +1,4 @@
<MagicNumbers
VIRTUAL_RESOLUTION_OFFSET_X="-2"
VIRTUAL_RESOLUTION_OFFSET_Y="0"
></MagicNumbers>

View File

@ -1,12 +0,0 @@
<MagicNumbers VIRTUAL_RESOLUTION_X="960"
VIRTUAL_RESOLUTION_Y="540"
CAMERA_NO_MOVE_BUFFER_NEAR_VIEWPORT_EDGE="0.0"
CAMERA_MOUSE_INTERPOLATION_SPEED="0.0"
CAMERA_POSITION_INTERPOLATION_SPEED="50.0"
DRAW_PARALLAX_BACKGROUND="0"
DEBUG_FREE_CAMERA_SPEED="10"
DEBUG_NO_LOGO_SPLASHES="1"
DEBUG_PAUSE_GRID_UPDATE="1"
DEBUG_PAUSE_BOX2D="1"
DEBUG_DISABLE_POSTFX_DITHERING="1">
</MagicNumbers>

240
files/message.lua Normal file
View File

@ -0,0 +1,240 @@
-- Copyright (c) 2019-2024 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-----------------------
-- Load global stuff --
-----------------------
--------------------------
-- Load library modules --
--------------------------
local Coords = require("coordinates")
local DebugAPI = require("noita-api.debug")
----------
-- Code --
----------
---Removes all messages with the AutoClose flag.
---Use this before you recreate all auto closing messages.
function Message:CloseAutoClose()
self.List = self.List or {}
for k, message in pairs(self.List) do
if message.AutoClose then
self.List[k] = nil
end
end
end
---Add a general runtime error message to the message list.
---This will always overwrite the last runtime error with the same id.
---@param id string
---@param ... string
function Message:ShowRuntimeError(id, ...)
self.List = self.List or {}
self.List["RuntimeError" .. id] = {
Type = "error",
Lines = { ... },
}
end
---Calls func and catches any exception.
---If there is one, a runtime error message will be shown to the user.
---@param id string
---@param func function
function Message:CatchException(id, func)
local ok, err = xpcall(func, debug.traceback)
if not ok then
print(string.format("An exception happened in %s: %s", id, err))
self:ShowRuntimeError(id, string.format("An exception happened in %s", id), err)
end
end
---Request the user to let the addon automatically reset some Noita settings.
function Message:ShowResetNoitaSettings()
self.List = self.List or {}
self.List["ResetNoitaSettings"] = {
Type = "info",
Lines = {
"You requested to reset some game settings like:",
"- Custom resolutions",
"- Screen-shake intensity",
" ",
"Press the following button to reset the settings and close Noita automatically:",
},
Actions = {
{ Name = "Reset and close (May corrupt current save!)", Hint = nil, HintDesc = nil, Callback = function() Modification:Reset() end },
},
}
end
---Request the user to let the addon automatically set Noita settings based on the given callback.
---@param callback function
---@param desc string -- What's wrong.
function Message:ShowSetNoitaSettings(callback, desc)
self.List = self.List or {}
self.List["SetNoitaSettings"] = {
Type = "warning",
Lines = {
"It seems that not all requested settings are applied to Noita:",
desc or "",
" ",
"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 = "Setup and close (May corrupt current save!)", Hint = nil, HintDesc = nil, Callback = callback },
},
AutoClose = true, -- This message will automatically close.
}
end
---Request the user to restart Noita.
---@param desc string -- What's wrong.
function Message:ShowRequestRestart(desc)
self.List = self.List or {}
self.List["RequestRestart"] = {
Type = "warning",
Lines = {
"It seems that not all requested settings are applied to Noita:",
desc or "",
" ",
"To resolve this issue, restart the game.",
},
AutoClose = true, -- This message will automatically close.
}
end
---Request the user to let the addon automatically set Noita settings based on the given callback.
---@param callback function
---@param desc string -- What's wrong.
function Message:ShowWrongResolution(callback, desc)
self.List = self.List or {}
self.List["WrongResolution"] = {
Type = "warning",
Lines = {
"The resolution changed:",
desc or "",
" ",
"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 = "Setup and close (May corrupt current save!)", Hint = nil, HintDesc = nil, Callback = callback },
},
AutoClose = true, -- This message will automatically close.
}
end
---Tell the user that there are files in the output directory.
function Message:ShowOutputNonEmpty()
self.List = self.List or {}
self.List["OutputNonEmpty"] = {
Type = "hint",
Lines = {
"There are already files in the output directory.",
"If you are continuing a capture session, ignore this message.",
" ",
"If you are about to capture a new map, make sure to delete all files in the output directory first."
},
Actions = {
{ Name = "Open output directory", Hint = nil, HintDesc = nil, Callback = function() os.execute("start .\\mods\\noita-mapcap\\output\\") end },
},
}
end
---Tell the user that some settings are not optimal.
---@param ... string
function Message:ShowGeneralSettingsProblem(...)
self.List = self.List or {}
self.List["GeneralSettingsProblem"] = {
Type = "hint",
Lines = { ... },
AutoClose = true, -- This message will automatically close.
}
end
---Tell the user that there is something wrong with the mod installation.
---@param ... string
function Message:ShowGeneralInstallationProblem(...)
self.List = self.List or {}
self.List["GeneralInstallationProblem"] = {
Type = "error",
Lines = { ... },
}
end
---Tell the user that some modification couldn't be applied because it is unsupported.
---@param realm "config"|"magicNumbers"|"processMemory"|"filePatches"
---@param name string
---@param value any
function Message:ShowModificationUnsupported(realm, name, value)
self.List = self.List or {}
self.List["ModificationFailed"] = self.List["ModificationFailed"] or {
Type = "warning",
}
-- 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

612
files/modification.lua Normal file
View File

@ -0,0 +1,612 @@
-- Copyright (c) 2022-2024 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Noita settings/configuration modifications.
-- We try to keep persistent modifications to a minimum, but some things have to be changed in order for the mod to work correctly.
-- There are 4 ways Noita can be modified by code:
-- - `config.xml`: These are persistent, and Noita needs to be force closed when changed from inside a mod.
-- - `magic_numbers.xml`: Persistent per world, can only be applied at mod startup.
-- - Process memory: Volatile, can be modified at runtime. Needs correct memory addresses to function.
-- - File patching: Volatile, can only be applied at mod startup.
--------------------------
-- Load library modules --
--------------------------
local CameraAPI = require("noita-api.camera")
local Coords = require("coordinates")
local ffi = require("ffi")
local Memory = require("memory")
local NXML = require("luanxml.nxml")
local Utils = require("noita-api.utils")
local Vec2 = require("noita-api.vec2")
local DebugAPI = require("noita-api.debug")
----------
-- Code --
----------
---Reads the current config from `config.xml` and returns it as table.
---@return table<string, string> config
function Modification.GetConfig()
local configFilename = Utils.GetSpecialDirectory("save-shared") .. "config.xml"
-- Read and modify config.
local f, err = io.open(configFilename, "r")
if not f then error(string.format("failed to read config file: %s", err)) end
local xml = NXML.parse(f:read("*a"))
f:close()
return xml.attr
end
---Will update Noita's `config.xml` with the values in the given table.
---
---This will force close Noita!
---@param config table<string, string> -- List of `config.xml` attributes that should be changed.
function Modification.SetConfig(config)
local configFilename = Utils.GetSpecialDirectory("save-shared") .. "config.xml"
-- Read and modify config.
local f, err = io.open(configFilename, "r")
if not f then error(string.format("failed to read config file: %s", err)) end
local xml = NXML.parse(f:read("*a"))
for k, v in pairs(config) do
xml.attr[k] = v
end
f:close()
-- Write modified config back.
local f, err = io.open(configFilename, "w")
if not f then error(string.format("failed to create config file: %s", err)) end
f:write(tostring(xml))
f:close()
-- We need to force close Noita, so it doesn't have any chance to overwrite the file.
os.exit(0)
end
---Will update Noita's `magic_numbers.xml` with the values in the given table.
---
---Should be called on mod initialization only.
---@param magic table<string, string> -- List of `magic_numbers.xml` attributes that should be changed.
function Modification.SetMagicNumbers(magic)
local xml = NXML.new_element("MagicNumbers", magic)
-- Write magic number file.
local f, err = io.open("mods/noita-mapcap/files/magic-numbers/generated.xml", "w")
if not f then error(string.format("failed to create config file: %s", err)) end
f:write(tostring(xml))
f:close()
ModMagicNumbersFileAdd("mods/noita-mapcap/files/magic-numbers/generated.xml")
end
---Changes some options directly by manipulating process memory.
---
---Related issue: https://github.com/Dadido3/noita-mapcap/issues/14.
---@param memory table
function Modification.SetMemoryOptions(memory)
-- Lookup table with the following hierarchy:
-- DevBuild -> OS -> BuildDate -> Option -> ModFunc.
local lookup = {
[true] = {
Windows = {
{_Offset = 0x00F77B0C, _BuildString = "Build Apr 23 2021 18:36:55", -- GOG dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010E3B6C)[0] = value end, -- Can be found by using Cheat Engine while toggling options in the F7 menu.
mGuiDisabled = function(value) ffi.cast("char*", 0x010E3B6D)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010E3B6E)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010E3B6F)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010E3B70)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010E3B71)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010E3B72)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010E3B73)[0] = value end,
},
{_Offset = 0x00F80384, _BuildString = "Build Apr 23 2021 18:40:40", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010EDEBC)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x010EDEBD)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010EDEBE)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010EDEBF)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010EDEC0)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010EDEC1)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010EDEC2)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010EDEC3)[0] = value end,
},
{_Offset = 0x00F8A7B4, _BuildString = "Build Mar 11 2023 14:05:19", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010F80EC)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x010F80ED)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010F80EE)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010F80EF)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010F80F0)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010F80F1)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010F80F2)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010F80F3)[0] = value end,
},
{_Offset = 0x00F8A8A4, _BuildString = "Build Jun 19 2023 14:14:52", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010F810C)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x010F810D)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010F810E)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010F810F)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010F8110)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010F8111)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010F8112)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010F8113)[0] = value end,
},
{_Offset = 0x00F82464, _BuildString = "Build Jul 26 2023 23:06:16", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010E9A5C)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x010E9A5D)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010E9A5E)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010E9A5F)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010E9A60)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010E9A61)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010E9A62)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010E9A63)[0] = value end,
},
{_Offset = 0x00FA654C, _BuildString = "Build Dec 19 2023 18:34:31", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x011154BC)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x011154BD)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x011154BE)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x011154BF)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x011154C0)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x011154C1)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x011154C2)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x011154C3)[0] = value end,
},
{_Offset = 0x00F8A9DC, _BuildString = "Build Dec 21 2023 00:07:29", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x010F814C)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x010F814D)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x010F814E)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x010F814F)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x010F8150)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x010F8151)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x010F8152)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x010F8153)[0] = value end,
},
{_Offset = 0x00F71DE4, _BuildString = "Build Dec 29 2023 23:36:18", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x0111758C)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x0111758D)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x0111758E)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0111758F)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x01117590)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x01117591)[0] = value end,
mPlayerNeverDies = function(value) ffi.cast("char*", 0x01117592)[0] = value end,
mFreezeAI = function(value) ffi.cast("char*", 0x01117593)[0] = value end,
},
{_Offset = 0x00F74FA8, _BuildString = "Build Dec 30 2023 19:37:04", -- Steam dev build.
mPostFxDisabled = function(value) ffi.cast("char*", 0x0111A5BC)[0] = value end,
mGuiDisabled = function(value) ffi.cast("char*", 0x0111A5BD)[0] = value end,
mGuiHalfSize = function(value) ffi.cast("char*", 0x0111A5BE)[0] = value end,
mFogOfWarOpenEverywhere = function(value) ffi.cast("char*", 0x0111A5BF)[0] = value end,
mTrailerMode = function(value) ffi.cast("char*", 0x0111A5C0)[0] = value end,
mDayTimeRotationPause = function(value) ffi.cast("char*", 0x0111A5C1)[0] = value end,
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] = {
Windows = {
{_Offset = 0x00E1C550, _BuildString = "Build Apr 23 2021 18:44:24", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x0063D8AD) -- Can be found by searching for the pattern C6 80 20 01 00 00 >01< 8B CF E8 FB 1D. The pointer has to point to the highlighted byte.
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 = 0x00E22E18, _BuildString = "Build Mar 11 2023 14:09:24", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006429ED)
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 = 0x00E22E18, _BuildString = "Build Jun 19 2023 14:18:46", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x006429ED)
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 = 0x00E146D4, _BuildString = "Build Jul 26 2023 23:10:16", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x0064390D)
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 = 0x00E333F4, _BuildString = "Build Dec 19 2023 18:38:23", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x00624C5D)
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 = 0x00E23EC4, _BuildString = "Build Dec 21 2023 00:11:06", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x0064246D)
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 = 0x00E14FA0, _BuildString = "Build Dec 29 2023 23:40:18", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x00625FFD)
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 = 0x00E180E8, _BuildString = "Build Dec 30 2023 19:40:49", -- Steam build.
enableModDetection = function(value)
local ptr = ffi.cast("char*", 0x00626EFD)
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 = 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[DebugAPI.IsDevBuild()]
level1 = level1 or {}
local level2 = level1[ffi.os]
level2 = level2 or {}
local level3 = {}
for _, v in ipairs(level2) do
if ffi.string(ffi.cast("char*", v._Offset)) == v._BuildString then
level3 = v
break
end
end
for k, v in pairs(memory) do
local modFunc = level3[k]
if modFunc ~= nil then
modFunc(v)
else
Message:ShowModificationUnsupported("processMemory", k, v)
end
end
end
---Applies patches to game files based on in the given table.
---
---Should be called on mod initialization only.
---@param patches table
function Modification.PatchFiles(patches)
-- Change constants in post_final.frag.
if patches.PostFinalConst then
local postFinal = ModTextFileGetContent("data/shaders/post_final.frag")
for k, v in pairs(patches.PostFinalConst) do
postFinal = postFinal:gsub(string.format(" %s%%s+=[^;]+;", k), string.format(" %s = %s;", k, tostring(v)), 1)
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.
---@return table config -- List of `config.xml` attributes that should be changed.
---@return table magic -- List of `magic_number.xml` attributes that should be changed.
---@return table memory -- List of options in RAM of this process that should be changed.
---@return table patches -- List of patches that should be applied to game files.
function Modification.RequiredChanges()
local config, magic, memory, patches = {}, {}, {}, {}
-- Does the user request a custom resolution?
local customResolution = (ModSettingGet("noita-mapcap.custom-resolution-live") and ModSettingGet("noita-mapcap.capture-mode") == "live")
or (ModSettingGet("noita-mapcap.custom-resolution-other") and ModSettingGet("noita-mapcap.capture-mode") ~= "live")
if customResolution then
config["window_w"] = tostring(Vec2(ModSettingGet("noita-mapcap.window-resolution")).x)
config["window_h"] = tostring(Vec2(ModSettingGet("noita-mapcap.window-resolution")).y)
config["internal_size_w"] = tostring(Vec2(ModSettingGet("noita-mapcap.internal-resolution")).x)
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"
config["internal_size_h"] = "720"
magic["VIRTUAL_RESOLUTION_X"] = "427"
magic["VIRTUAL_RESOLUTION_Y"] = "242"
magic["GRID_RENDER_BORDER"] = "2"
magic["VIRTUAL_RESOLUTION_OFFSET_X"] = "-1"
magic["VIRTUAL_RESOLUTION_OFFSET_Y"] = "-1"
end
-- 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.
magic["DEBUG_PAUSE_GRID_UPDATE"] = ModSettingGet("noita-mapcap.disable-physics") and "1" or "0"
magic["DEBUG_PAUSE_BOX2D"] = ModSettingGet("noita-mapcap.disable-physics") and "1" or "0"
magic["DEBUG_DISABLE_POSTFX_DITHERING"] = ModSettingGet("noita-mapcap.disable-postfx") and "1" or "0"
-- These magic numbers stop any grid updates (pixel physics), even in the release build.
-- But any Box2D objects glitch, therefore the mod option (disable-physics) is disabled and hidden in the non dev build.
--magic["GRID_MAX_UPDATES_PER_FRAME"] = ModSettingGet("noita-mapcap.disable-physics") and "0" or "128"
--magic["GRID_MIN_UPDATES_PER_FRAME"] = ModSettingGet("noita-mapcap.disable-physics") and "1" or "40"
if ModSettingGet("noita-mapcap.disable-postfx") then
patches.PostFinalConst = {
ENABLE_REFRACTION = false,
ENABLE_LIGHTING = false,
ENABLE_FOG_OF_WAR = false,
ENABLE_GLOW = false,
ENABLE_GAMMA_CORRECTION = false,
ENABLE_PATH_DEBUG = false,
FOG_FOREGROUND = "vec4(0.0,0.0,0.0,1.0)",
FOG_BACKGROUND = "vec3(0.0,0.0,0.0)",
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
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
memory["enableModDetection"] = 0
else
-- Don't actively (re)enable mod detection.
--memory["enableModDetection"] = 1
end
-- Disables or hides most of the UI.
-- The game is still somewhat playable this way.
if ModSettingGet("noita-mapcap.disable-ui") then
magic["INVENTORY_GUI_ALWAYS_VISIBLE"] = "0"
magic["UI_BARS2_OFFSET_X"] = "100"
else
-- Reset to default.
magic["INVENTORY_GUI_ALWAYS_VISIBLE"] = "1"
magic["UI_BARS2_OFFSET_X"] = "-40"
end
return config, magic, memory, patches
end
---Sets the camera free if required by the mod settings.
---@param force boolean|nil -- If true, the camera will be set free regardless.
function Modification.SetCameraFree(force)
if force ~= nil then CameraAPI.SetCameraFree(force) return end
local captureMode = ModSettingGet("noita-mapcap.capture-mode")
local spiralOrigin = ModSettingGet("noita-mapcap.capture-mode-spiral-origin")
-- Allow free roaming when in spiral mode with origin being the current position.
if captureMode == "spiral" and spiralOrigin == "current" then
CameraAPI.SetCameraFree(true)
return
end
CameraAPI.SetCameraFree(false)
end
---Will change the game settings according to `Modification.RequiredChanges()`.
---
---This will force close Noita!
function Modification.AutoSet()
local config, magic = Modification.RequiredChanges()
Modification.SetConfig(config)
end
---Will reset all persistent settings that may have been changed by this mod.
---
---This will force close Noita!
function Modification.Reset()
local config = {
window_w = "1280",
window_h = "720",
internal_size_w = "1280",
internal_size_h = "720",
backbuffer_width = "1280",
backbuffer_height = "720",
screenshake_intensity = "0.7",
}
Modification.SetConfig(config)
end

View File

@ -0,0 +1,32 @@
-- Copyright (c) 2022 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
-- Emulate and override some functions and tables to make everything conform more to standard lua.
-- This will make `require` work, even in sandboxes with restricted Noita API.
local libPath = "mods/noita-mapcap/files/libraries/"
dofile(libPath .. "noita-api/compatibility.lua")(libPath)
local EntityAPI = require("noita-api.entity")
local oldPerkSpawn = perk_spawn
---Spawns a perk.
---@param x number
---@param y number
---@param perkID integer
---@param dontRemoveOtherPerks boolean
---@return number|nil
function perk_spawn(x, y, perkID, dontRemoveOtherPerks)
local entity = EntityAPI.Wrap(oldPerkSpawn(x, y, perkID, dontRemoveOtherPerks))
if entity == nil then return end
-- Remove the SpriteOffsetAnimatorComponent components from the entity.
local components = entity:GetComponents("SpriteOffsetAnimatorComponent")
for _, component in ipairs(components) do
entity:RemoveComponent(component)
end
return entity.ID
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

BIN
files/ui-gfx/hint-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

BIN
files/ui-gfx/progress-a.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

BIN
files/ui-gfx/progress-b.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

BIN
files/ui-gfx/stop-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

View File

@ -1,46 +1,215 @@
-- Copyright (c) 2019 David Vogel -- Copyright (c) 2019-2022 David Vogel
-- --
-- This software is released under the MIT License. -- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT -- https://opensource.org/licenses/MIT
UiHide = false -----------------------
local UiReduce = false -- Load global stuff --
-----------------------
async_loop( -- TODO: Wrap Noita utilities and wrap them into a table: https://stackoverflow.com/questions/9540732/loadfile-without-polluting-global-environment
function() require("utilities") -- Loads Noita's utilities from `data/scripts/lib/utilities.lua`.
if modGUI ~= nil then
GuiStartFrame(modGUI)
GuiLayoutBeginVertical(modGUI, 50, 50) --------------------------
if not UiReduce then -- Load library modules --
GuiTextCentered(modGUI, 0, 0, "You can freely look around and search a place to start capturing.") --------------------------
GuiTextCentered(modGUI, 0, 0, "The mod will then take images in a spiral around your current view.")
GuiTextCentered(modGUI, 0, 0, "Use ESC and close the game to stop the process.") ----------
GuiTextCentered( -- Code --
modGUI, ----------
0,
0, ---Splits the given string to fit inside maxLength.
'You can resume capturing just by restarting noita and pressing "Start capturing map" again,' ---@param gui userdata
) ---@param text string
GuiTextCentered(modGUI, 0, 0, "the mod will skip already captured files.") ---@param maxLength number -- In UI pixels.
GuiTextCentered( ---@return string[]
modGUI, local function splitString(gui, text, maxLength)
0, local splitted = {}
0,
'If you want to start a new map, you have to delete all images from the "output" folder!' local first, rest = text, ""
)
if GuiButton(modGUI, 0, 0, ">> Start capturing map <<", 1) then while first:len() > 0 do
startCapturing() local width, height = GuiGetTextDimensions(gui, first, 1, 2)
UiReduce = true if width <= maxLength then
table.insert(splitted, first)
first, rest = rest, ""
else
first, rest = first:sub(1, -2), first:sub(-1, -1) .. rest
end end
end end
if not UiHide then
local x, y = GameGetCameraPos()
GuiTextCentered(modGUI, 0, 0, string.format("Coordinates: %d, %d", x, y))
end
GuiLayoutEnd(modGUI)
end
wait(0)
return splitted
end
---Returns unique IDs for the widgets.
---`_ResetID` has to be called every time before the UI is rebuilt.
---@return integer
function UI:_GenID()
self.CurrentID = (self.CurrentID or 0) + 1
return self.CurrentID
end
function UI:_ResetID()
self.CurrentID = nil
end
---Stops the UI from drawing for the next few frames.
---@param frames integer
function UI:SuspendDrawing(frames)
self.suspendFrames = math.max(self.suspendFrames or 0, frames)
end
function UI:_DrawToolbar()
local gui = self.gui
GuiZSet(gui, 0)
GuiLayoutBeginHorizontal(gui, 2, 2, true, 2, 2)
local captureMode = tostring(ModSettingGet("noita-mapcap.capture-mode"))
if Capture.MapCapturingCtx:IsRunning() then
local clicked, clickedRight = GuiImageButton(gui, self:_GenID(), 0, 0, "", "mods/noita-mapcap/files/ui-gfx/stop-16x16.png")
GuiTooltip(gui, "Stop capture", "Stop the capturing process.\n \nRight click: Reset any modifications that this mod has done to Noita.")
if clicked then Capture:StopCapturing() end
if clickedRight then Message:ShowResetNoitaSettings() end
else
local clicked, clickedRight = GuiImageButton(gui, self:_GenID(), 0, 0, "", "mods/noita-mapcap/files/ui-gfx/record-16x16.png")
GuiTooltip(gui, string.format("Start %s capture", captureMode), "Go into mod settings to configure the capturing process.\n \nRight click: Reset any modifications that this mod has done to Noita.")
if clicked then Capture:StartCapturing() end
if clickedRight then Message:ShowResetNoitaSettings() end
end end
)
local clicked = GuiImageButton(gui, self:_GenID(), 0, 0, "", "mods/noita-mapcap/files/ui-gfx/open-output-16x16.png")
GuiTooltip(gui, "Open output directory", "Reveals the output directory in your file browser.")
if clicked then os.execute("start .\\mods\\noita-mapcap\\output\\") end
local clicked = GuiImageButton(gui, self:_GenID(), 0, 0, "", "mods/noita-mapcap/files/ui-gfx/stitch-16x16.png")
GuiTooltip(gui, "Open stitch directory", "Reveals the directory of the stitching tool in your file browser.")
if clicked then os.execute("start .\\mods\\noita-mapcap\\bin\\stitch\\") end
GuiLayoutEnd(gui)
end
function UI:_DrawMessages(messages)
local gui = self.gui
-- Abort if there is no messages list.
if not messages then return end
local screenWidth, screenHeight = GuiGetScreenDimensions(gui)
GuiZSet(gui, 0)
-- Unfortunately you can't stack multiple layout containers with the same direction.
-- So keep track of the y position manually.
local posY = 60
for key, message in pairs(messages) do
GuiZSet(gui, -10)
GuiBeginAutoBox(gui)
GuiLayoutBeginHorizontal(gui, 27, posY, true, 5, 0) posY = posY + 20
if message.Type == "warning" or message.Type == "error" then
GuiImage(gui, self:_GenID(), 0, 0, "mods/noita-mapcap/files/ui-gfx/warning-16x16.png", 1, 1, 0, 0, 0, "")
elseif message.Type == "hint" or message.Type == "info" then
GuiImage(gui, self:_GenID(), 0, 0, "mods/noita-mapcap/files/ui-gfx/hint-16x16.png", 1, 1, 0, 0, 0, "")
else
GuiImage(gui, self:_GenID(), 0, 0, "mods/noita-mapcap/files/ui-gfx/hint-16x16.png", 1, 1, 0, 0, 0, "")
end
GuiLayoutBeginVertical(gui, 0, 0, false, 0, 0)
if type(message.Lines) == "table" then
for _, line in ipairs(message.Lines) do
for splitLine in tostring(line):gmatch("[^\n]+") do
for _, splitLine in ipairs(splitString(gui, splitLine, screenWidth - 80)) do
GuiText(gui, 0, 0, splitLine) posY = posY + 11
end
end
end
end
if type(message.Actions) == "table" then
posY = posY + 11
for _, action in ipairs(message.Actions) do
local clicked = GuiButton(gui, self:_GenID(), 0, 11, ">" .. action.Name .. " <") posY = posY + 11
if action.Hint or action.HintDesc then
GuiTooltip(gui, action.Hint or "", action.HintDesc or "")
end
if clicked then
local ok, err = pcall(action.Callback)
if not ok then
Message:ShowRuntimeError("MessageAction", "Message action error:", err)
end
messages[key] = nil
end
end
end
GuiLayoutEnd(gui)
if not message.AutoClose then
local clicked = GuiImageButton(gui, self:_GenID(), 5, 0, "", "mods/noita-mapcap/files/ui-gfx/dismiss-8x8.png")
--GuiTooltip(gui, "Dismiss message", "")
if clicked then messages[key] = nil end
end
GuiLayoutEnd(gui)
GuiZSet(gui, -9)
GuiEndAutoBoxNinePiece(gui, 5, 0, 0, false, 0, "data/ui_gfx/decorations/9piece0_gray.png", "data/ui_gfx/decorations/9piece0_gray.png")
end
end
function UI:_DrawProgress()
local gui = self.gui
-- Check if there is progress to show.
local state = Capture.MapCapturingCtx:GetState()
if not state then return end
local factor
if state.Current and state.Max > 0 then
factor = state.Current / state.Max
end
local width, height = GuiGetScreenDimensions(gui)
local widthHalf, heightHalf = math.floor(width/2), math.floor(height/2)
GuiZSet(gui, -20)
local barWidth = width - 60
local y = heightHalf
if factor then
GuiImageNinePiece(gui, self:_GenID(), 30, y, barWidth, 9, 1, "mods/noita-mapcap/files/ui-gfx/progress-a.png", "mods/noita-mapcap/files/ui-gfx/progress-a.png")
GuiImageNinePiece(gui, self:_GenID(), 30, y, math.floor(barWidth * factor + 0.5), 9, 1, "mods/noita-mapcap/files/ui-gfx/progress-b.png", "mods/noita-mapcap/files/ui-gfx/progress-b.png")
GuiOptionsAddForNextWidget(gui, GUI_OPTION.Align_HorizontalCenter)
GuiText(gui, widthHalf, y, string.format("%d of %d (%.1f%%)", state.Current, state.Max, factor*100)) y = y + 11
y = y + 15
end
if state.WaitFrames then
GuiOptionsAddForNextWidget(gui, GUI_OPTION.Align_HorizontalCenter)
GuiText(gui, widthHalf, y, string.format("Waiting for %d frames.", state.WaitFrames)) y = y + 11
end
end
function UI:Draw()
self.gui = self.gui or GuiCreate()
local gui = self.gui
-- Skip drawing if we are asked to do so.
-- TODO: Find a way to suspend UI drawing, but still being able to receive events
if self.suspendFrames and self.suspendFrames > 0 then self.suspendFrames = self.suspendFrames - 1 return end
self.suspendFrames = nil
-- Reset ID generator.
self:_ResetID()
GuiStartFrame(gui)
GuiIdPushString(gui, "noita-mapcap")
self:_DrawToolbar()
self:_DrawMessages(Message.List)
self:_DrawProgress()
GuiIdPop(gui)
end

View File

@ -1,99 +0,0 @@
-- Copyright (c) 2019 David Vogel
--
-- This software is released under the MIT License.
-- https://opensource.org/licenses/MIT
function SplitStringByLength(string, length)
local chunks = {}
for i = 1, #string, length do
table.insert(chunks, string:sub(i, i + length - 1))
end
return chunks
end
-- Improved version of GamePrint, that behaves more like print.
local oldGamePrint = GamePrint
function GamePrint(...)
local arg = {...}
local result = ""
for i, v in ipairs(arg) do
result = result .. tostring(v) .. " "
end
for line in result:gmatch("[^\r\n]+") do
for i, v in ipairs(splitStringByLength(line, 100)) do
oldGamePrint(v)
end
end
end
function getPlayer()
local players = EntityGetWithTag("player_unit")
if players == nil or #players < 1 then
return nil
end
return players[1]
end
function getPlayerPos()
return EntityGetTransform(getPlayer())
end
function teleportPlayer(x, y)
EntitySetTransform(getPlayer(), x, y)
end
function setPlayerHP(hp)
local damagemodels = EntityGetComponent(getPlayer(), "DamageModelComponent")
if damagemodels ~= nil then
for i, damagemodel in ipairs(damagemodels) do
ComponentSetValue(damagemodel, "max_hp", hp)
ComponentSetValue(damagemodel, "hp", hp)
end
end
end
function addEffectToEntity(entity, gameEffect)
local gameEffectComp = GetGameEffectLoadTo(entity, gameEffect, true)
if gameEffectComp ~= nil then
ComponentSetValue(gameEffectComp, "frames", "-1")
end
end
function addPerkToPlayer(perkID)
local playerEntity = getPlayer()
local x, y = getPlayerPos()
local perkData = get_perk_with_id(perk_list, perkID)
-- Add effect
addEffectToEntity(playerEntity, perkData.game_effect)
-- Add ui icon etc
--[[local perkIcon = EntityCreateNew("")
EntityAddComponent(
perkIcon,
"UIIconComponent",
{
name = perkData.ui_name,
description = perkData.ui_description,
icon_sprite_file = perkData.ui_icon
}
)
EntityAddChild(playerEntity, perkIcon)]]
--local effect = EntityLoad("data/entities/misc/effect_protection_all.xml", x, y)
--EntityAddChild(playerEntity, effect)
end
function fileExists(fileName)
local f = io.open(fileName, "r")
if f ~= nil then
io.close(f)
return true
else
return false
end
end

49
go.mod
View File

@ -1,18 +1,43 @@
module github.com/Dadido3/noita-mapcap module github.com/Dadido3/noita-mapcap
go 1.13 go 1.22
require ( require (
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 // indirect github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect github.com/Dadido3/go-libwebp v0.3.0
github.com/gen2brain/shm v0.0.0-20180314170312-6c18ff7f8b90 // indirect github.com/cheggaaa/pb/v3 v3.1.4
github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565 github.com/coreos/go-semver v0.3.1
github.com/kbinani/screenshot v0.0.0-20190719135742-f06580e30cdc github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237
github.com/lxn/win v0.0.0-20190919090605-24c5960b03d8 // indirect
github.com/manifoldco/promptui v0.3.2
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/schollz/progressbar/v2 v2.14.0 github.com/tdewolff/canvas v0.0.0-20231218015800-2ad5075e9362
github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8 // indirect golang.org/x/exp v0.0.0-20231219180239-dc181d75b848
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 )
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
require (
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/benoitkugler/textlayout v0.3.0 // indirect
github.com/benoitkugler/textprocessing v0.0.3 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/dsnet/compress v0.0.1 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/gen2brain/shm v0.1.0 // indirect
github.com/go-fonts/latin-modern v0.3.2 // indirect
github.com/go-fonts/liberation v0.3.2 // indirect
github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea // indirect
github.com/go-text/typesetting v0.0.0-20231219150831-cc0073efdbb4 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jezek/xgb v1.1.1 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
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.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
) )

179
go.sum
View File

@ -1,82 +1,117 @@
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= git.sr.ht/~sbinet/gg v0.5.0 h1:6V43j30HM623V329xA9Ntq+WJrMjDxRjuAB1LFWF5m8=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= git.sr.ht/~sbinet/gg v0.5.0/go.mod h1:G2C0eRESqlKhS7ErsNey6HHrqU1PwsnCQlekFi9Q2Oo=
github.com/alecthomas/gometalinter v2.0.11+incompatible h1:ENdXMllZNSVDTJUUVIzBW9CSEpntTrQa76iRsEFLX/M= github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1 h1:LejjvYg4tCW5HO7q/1nzPrprh47oUD9OUySQ29pDp5c=
github.com/alecthomas/gometalinter v2.0.11+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk= github.com/1lann/promptui v0.8.1-0.20220708222609-81fad96dd5e1/go.mod h1:cnC/60IoLiDM0GhdKYJ6oO7AwpZe1IQfPnSKlAURgHw=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v/9acCWA64Yz/HDLqjcRTvc0noQACi4MsJw=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= 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=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.3.0 h1:2ehWXEkgb6RUokTjXh1LzdGwG4dRP6X3dqhYYDYhUVk=
github.com/benoitkugler/textlayout v0.3.0/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo=
github.com/benoitkugler/textprocessing v0.0.3 h1:Q2X+Z6vxuW5Bxn1R9RaNt0qcprBfpc2hEUDeTlz90Ng=
github.com/benoitkugler/textprocessing v0.0.3/go.mod h1:/4bLyCf1QYywunMK3Gf89Nhb50YI/9POewqrLxWhxd4=
github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY=
github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8=
github.com/cheggaaa/pb/v3 v3.1.4 h1:DN8j4TVVdKu3WxVwcRKu0sG00IIU6FewoABZzXbRQeo=
github.com/cheggaaa/pb/v3 v3.1.4/go.mod h1:6wVjILNBaXMs8c21qRiaUM8BR82erfgau1DQ4iUXmSA=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
github.com/gen2brain/shm v0.0.0-20180314170312-6c18ff7f8b90 h1:QagTG5rauLt6pVVEhnVSrlIX4ifhVIZOwmw6x6D8TUw= github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
github.com/gen2brain/shm v0.0.0-20180314170312-6c18ff7f8b90/go.mod h1:uF6rMu/1nvu+5DpiRLwusA6xB8zlkNoGzKn8lmYONUo= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3 h1:I4BOK3PBMjhWfQM2zPJKK7lOBGsrsvOB7kBELP33hiE= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565 h1:KBAlCAY6eLC44FiEwbzEbHnpVlw15iVM4ZK8QpRIp4U= github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY=
github.com/google/hilbert v0.0.0-20181122061418-320f2e35a565/go.mod h1:xn6EodFfRzV6j8NXQRPjngeHWlrpOrsZPKuuLRThU1k= github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA=
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg= github.com/go-fonts/latin-modern v0.3.2 h1:M+Sq24Dp0ZRPf3TctPnG1MZxRblqyWC/cRUL9WmdaFc=
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE= github.com/go-fonts/latin-modern v0.3.2/go.mod h1:9odJt4NbRrbdj4UAMuLVd4zEukf6aAEKnDaQga0whqQ=
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY= github.com/go-fonts/liberation v0.3.2 h1:XuwG0vGHFBPRRI8Qwbi5tIvR3cku9LUfZGq/Ar16wlQ=
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU= github.com/go-fonts/liberation v0.3.2/go.mod h1:N0QsDLVUQPy3UYg9XAc3Uh3UDMp2Z7M1o4+X98dXkmI=
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea h1:DfZQkvEbdmOe+JK2TMtBM+0I9GSdzE2y/L1/AmD8xKc=
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea/go.mod h1:Y7Vld91/HRbTBm7JwoI7HejdDB0u+e9AUBO9MB7yuZk=
github.com/kbinani/screenshot v0.0.0-20190719135742-f06580e30cdc h1:kGFotla6Dyr6a2ILeExAHlttPgJtnoP/GIw2uVN/4h4= github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/kbinani/screenshot v0.0.0-20190719135742-f06580e30cdc/go.mod h1:f8GY5V3lRzakvEyr49P7hHRYoHtPr8zvj/7JodCoRzw= github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/go-text/typesetting v0.0.0-20231219150831-cc0073efdbb4 h1:MKnPksPov832ct2c9a40QUB+2lgf2pBo7N92TxAAFA8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/go-text/typesetting v0.0.0-20231219150831-cc0073efdbb4/go.mod h1:MrLApvxyzSW0MhQqLc484jkUWYX4wsEvEqDosB5Io80=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/go-text/typesetting-utils v0.0.0-20231204162240-fa4dc564ba79 h1:3yBOzx29wog0i7TnUBMcp90EwIb+A5kqmr5vny1UOm8=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/go-text/typesetting-utils v0.0.0-20231204162240-fa4dc564ba79/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/lxn/win v0.0.0-20190919090605-24c5960b03d8 h1:RVMGIuuNgrpGB7I79f6xfhGCkpN47IaEGh8VTM0p7Xc= github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/lxn/win v0.0.0-20190919090605-24c5960b03d8/go.mod h1:ouWl4wViUNh8tPSIwxTVMuS014WakR1hqvBc2I0bMoA= github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 h1:YOp8St+CM/AQ9Vp4XYm4272E77MptJDHkwypQHIRl9Q=
github.com/manifoldco/promptui v0.3.2 h1:rir7oByTERac6jhpHUPErHuopoRDvO3jxS+FdadEns8= github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237/go.mod h1:e7qQlOY68wOz4b82D7n+DdaptZAi+SHW0+yKiWZzEYE=
github.com/manifoldco/promptui v0.3.2/go.mod h1:8JU+igZ+eeiiRku4T5BjtKh2ms8sziGpSYl1gN8Bazw= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n v2.0.2+incompatible h1:Xt6dluut3s2zBUha8/3sj6atWMQbFioi9OMqUGH9khg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/schollz/progressbar/v2 v2.14.0 h1:vo7bdkI9E4/CIk9DnL5uVIaybLQiVtiCC2vO+u9j5IM= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/schollz/progressbar/v2 v2.14.0/go.mod h1:6YZjqdthH6SCZKv2rqGryrxPtfmRB/DWZxSMfCXPyD8= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/tdewolff/canvas v0.0.0-20231218015800-2ad5075e9362 h1:E9HkFtZcjoZQCaSyb2Finw4jhC0NWOJ2DCCoAMYrXLg=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/tdewolff/canvas v0.0.0-20231218015800-2ad5075e9362/go.mod h1:hGxWCl1a3KdYh6pxYy9sa9jLAlmKLMeuCSCjjy39iVE=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/tdewolff/minify/v2 v2.20.10 h1:iz9IkdRqD2pyneib/AvTas23RRG5TnuUFNcNVKmL/jU=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/tdewolff/minify/v2 v2.20.10/go.mod h1:xSJ9fXIfyuEMex88JT4jl8GvXnl/RzWNdqD96AqKlX0=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/tdewolff/parse/v2 v2.7.7 h1:V+50eFDH7Piw4IBwH8D8FtYeYbZp3T4SCtIvmBSIMyc=
github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8 h1:RB0v+/pc8oMzPsN97aZYEwNuJ6ouRJ2uhjxemJ9zvrY= github.com/tdewolff/parse/v2 v2.7.7/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8/go.mod h1:IlWNj9v/13q7xFbaK4mbyzMNwrZLaWSHx/aibKIZuIg= github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9 h1:vY5WqiEon0ZSTGM3ayVVi+twaHKHDFUVloaQ/wug9/c= github.com/tdewolff/test v1.0.11-0.20231121141655-2d5236e10ae4 h1:CmTImZFElFD07EUPqgMEraDMnJX1E5oJKeibjg0SC2c=
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk= github.com/tdewolff/test v1.0.11-0.20231121141655-2d5236e10ae4/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uyPfhPeDWD+n+JgppptE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3 h1:x/bBzNauLQAlE3fLku/xy92Y8QwKX5HZymrMz2IiKFc= golang.org/x/exp v0.0.0-20231219180239-dc181d75b848/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
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.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-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd h1:DBH9mDw0zluJT/R+nGuV3jWFWLFaHyYZWD4tOT+cjn0= golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20181122213734-04b5d21e00f1 h1:bsEj/LXbv3BCtkp/rBj9Wi/0Nde4OMaraIZpndHAhdI= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20181122213734-04b5d21e00f1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c h1:vTxShRUnK60yd8DZU+f95p1zSLj814+5CuEh7NjF2/Y= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c/go.mod h1:3HH7i1SgMqlzxCcBmUHW657sD4Kvv9sC3HpL3YukzwA= 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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= star-tex.org/x/tex v0.4.0 h1:AXUwgpnHLCxZUWW3qrmjv6ezNhH3PjUVBuLLejz2cgU=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= star-tex.org/x/tex v0.4.0/go.mod h1:w91ycsU/DkkCr7GWr60GPWqp3gn2U+6VX71T0o8k8qE=

113
images/coordinates.pdn Normal file

File diff suppressed because one or more lines are too long

BIN
images/coordinates.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
images/scale32_extended.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Some files were not shown because too many files have changed in this diff Show More