mapsyncer-paper: server side of MapSyncer for Xaero's World Map, discovery-limited

Paper 26.2 plugin speaking the stock MapSyncer client mod's protocol.
Renders Xaero region zips from the world's region files, but only for chunks
players have actually been sent (PlayerChunkLoadEvent), seeded once from
InhabitedTime. Shared or per-player visibility, background render cycle,
per-player streaming with hash/timestamp skipping, Gitea Actions release
workflow.

Vendors the MCA parser and Xaero writer from upstream MapSyncer (GPL-3.0),
see NOTICE.md.

Claude-Session: https://claude.ai/code/session_011FePLXwBsCGLTzaSkk1Z6V
This commit is contained in:
2026-09-07 00:04:43 +03:00
commit 7fb3a82482
67 changed files with 10934 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
name: Build
run-name: ${{ gitea.actor }} is building mapsyncer-paper 🗺️
on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
# Must be >= the `javaRelease` in gradle.properties (the class-file
# target that matches the Minecraft server's own JVM). Newer is fine:
# javac's --release pins the output regardless.
java-version: '25'
- name: Cache Gradle
uses: actions/cache@v4
with:
# The paperweight dev bundle is decompiled/remapped into
# ~/.gradle/caches/paperweight on first use; it is several minutes of
# work, so keep it between runs.
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts', 'gradle/wrapper/gradle-wrapper.properties', 'gradle.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build and test
run: ./gradlew --no-daemon build
- name: Name the artifact
id: jar
run: |
set -euo pipefail
path="$(ls build/libs/mapsyncer-paper-*.jar | grep -vE -- '-(sources|thin)\.jar$' | head -1)"
echo "path=$path" >> "$GITHUB_OUTPUT"
echo "name=$(basename "$path")" >> "$GITHUB_OUTPUT"
ls -la build/libs/
- name: Upload the plugin jar
# v3, not v4, deliberately. v4 needs the artifact service v2, which only
# some runners in this fleet advertise; `runs-on: ubuntu-latest` can land
# on any of them, and v4 fails there with GHESNotSupportedError. v3 uses
# the older protocol, which every runner still serves.
uses: actions/upload-artifact@v3
with:
name: mapsyncer-paper
path: build/libs/mapsyncer-paper-*.jar
retention-days: 90
- name: Publish a release for a tag
if: startsWith(gitea.ref, 'refs/tags/v')
env:
# Injected by Gitea Actions for the running repository.
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
TAG: ${{ gitea.ref_name }}
JAR: ${{ steps.jar.outputs.path }}
JAR_NAME: ${{ steps.jar.outputs.name }}
run: |
set -euo pipefail
# Reuse the release if the tag was built before, so a re-run does not
# fail on "already exists".
id="$(curl -sS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" \
| sed -n 's/.*"id":[[:space:]]*\([0-9]\+\).*/\1/p' | head -1 || true)"
if [ -z "$id" ]; then
id="$(curl -sS -X POST -H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" "$API/releases" \
| sed -n 's/.*"id":[[:space:]]*\([0-9]\+\).*/\1/p' | head -1)"
fi
curl -sS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$JAR" \
"$API/releases/$id/assets?name=$JAR_NAME" > /dev/null
echo "Attached $JAR_NAME to release $TAG"
+12
View File
@@ -0,0 +1,12 @@
build/
.gradle/
*.class
*.log
# IDEs
.idea/
*.iml
.vscode/
.settings/
.classpath
.project
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+41
View File
@@ -0,0 +1,41 @@
# Third-party code
## MapSyncer for Xaero's World Map
Everything under `src/main/java/com/mapsyncer/` and the two renderer adapters
`src/main/java/lv/janhouse/mapsyncer/render/BlockPropertyResolver.java` and
`PlaceholderBlockGetter.java` are taken from
MapSyncer for Xaero's World Map
https://github.com/RuoChennn/MapSyncer-for-XaeroWorldmap
commit 116986ac17d9c18706bbbd9df7851bd365bb67f1 (2026-09-02, v1.0.4 beta)
Copyright (c) RuoChennn and contributors
Licensed under the GNU General Public License v3.0
That project is the reference implementation of the wire protocol this plugin
speaks and of the Xaero region-file writer. This plugin as a whole is therefore
also distributed under the GPL-3.0 (see `LICENSE`).
Local modifications to the vendored code, kept deliberately small so upstream
diffs stay readable:
- `mca/ChunkMask.java` — new: lets a conversion skip chunks nobody has
discovered (they become "no data" tiles instead of terrain).
- `mca/convert/io/McaRegionLoader.java`, `mca/convert/RegionConversionPipeline.java`
— overloads taking a `ChunkMask`.
- `mca/McaReader.java` — imports the plain `net.jpountz` LZ4 package that the
Paper server already ships (upstream relocates its own copy).
- `mca/ChunkDataParser.java` — heightmap bit width uses vanilla's
`ceillog2(height + 1)` (upstream under-counts by one for 256-high
dimensions); accepted chunk statuses include `initialize_light`.
- `XaeroWriter.java` — moved from `com.mapsyncer.server` to `com.mapsyncer`.
- `render/BlockPropertyResolver.java`, `render/PlaceholderBlockGetter.java`
package moved; the cache-size constant is inlined.
Everything else in `lv.janhouse.mapsyncer` is original to this repository.
## lz4-java
LZ4-compressed region files are read through the `net.jpountz.lz4` classes the
Paper server bundles (`at.yawk.lz4:lz4-java`, Apache License 2.0). Nothing of
it is redistributed in this jar.
+97
View File
@@ -0,0 +1,97 @@
# mapsyncer-paper
The server half of [MapSyncer for Xaero's World Map](https://github.com/RuoChennn/MapSyncer-for-XaeroWorldmap),
as a Paper plugin — with one twist: the map players receive is built from
**what players have actually discovered**, not from every chunk the server has
ever generated.
Players run the stock MapSyncer client mod (Fabric or NeoForge, same Minecraft
version as the server) next to Xaero's World Map. The plugin speaks the mod's
wire protocol byte for byte, so nothing on the client needs patching.
## What it does
1. **Tracks discovery.** Every chunk the server sends to a player is recorded
for that player (`PlayerChunkLoadEvent`). That is exactly the set Xaero's
client maps on its own, so the server's picture of a region is always a
superset of the player's — which matters, because the client mod overwrites
region files wholesale.
2. **Seeds history once.** On first start the plugin scans each world's region
files and marks chunks with `InhabitedTime > 0` as discovered. Pre-generated
terrain (Chunky, distant-terrain LOD generation) has `InhabitedTime = 0` and
stays hidden until somebody walks there.
3. **Renders on demand.** A region is converted from its `.mca` file into a
Xaero region zip only for the discovered chunks; everything else becomes a
"no data" tile. Re-rendering happens when the region file changes or the
discovery mask grows.
4. **Streams to the mod.** On join the client gets a hello, polls
periodically, and receives whatever changed since it last asked, hash- and
timestamp-checked per region layer.
Two visibility modes (`config.yml`):
| `visibility` | Player receives |
| --- | --- |
| `shared` (default) | the union of everyone's discoveries |
| `own` | only what they discovered themselves, plus the seeded history |
## Requirements
- Paper 26.2 on Java 25 (the build compiles against Paper's Mojang-mapped
internals via paperweight; other versions need a rebuild).
- Clients: Xaero's World Map 1.40.11+ and the MapSyncer client mod for the
same Minecraft version.
- Xaero's **Multiplayer world map** setting should be *Single*. The client mod
writes into the newest `mw$…` folder; Xaero's "world spawn" mode uses folders
without the `$` and would never see the synced files.
## Install
Drop the jar from the latest release into `plugins/`, start the server, read
the generated `plugins/MapSyncer/config.yml`. The first start scans every
tracked world once (a few minutes for a multi-gigabyte world, in the
background); nothing is served until the first render cycle after that.
Dimension folders on the client follow Xaero's own naming: vanilla keys map to
`null`, `DIM-1`, `DIM1`; anything else, including Multiverse worlds, to
`namespace$path` (e.g. `minecraft$survival`).
## Commands
```
/mapsync status counters: discovery, cache, syncs, clients with the mod
/mapsync worlds per-world mapping, layers, region directory
/mapsync render [force] run a render cycle now; force drops the cache first
/mapsync seed <world|all> re-run the InhabitedTime scan
/mapsync player <name> what one player has discovered
/mapsync reload re-read config.yml
```
Permission `mapsyncer.admin` (default: op).
## Data
Everything lives under `plugins/MapSyncer/`:
```
discovery/<world>.bin discovery bitmaps (per region: union, seeded, per player)
cache/<scope>/<dim>/[caves/<L>/]<x>_<z>.zip rendered regions; scope = shared or a player UUID
cache/<scope>/index.tsv timestamps, CRC32s, content stamps
```
Deleting `cache/` is always safe (it is rebuilt). Deleting `discovery/` loses
who-discovered-what; the next start re-seeds from `InhabitedTime`.
## Building
```
./gradlew build # runs the unit tests; jar in build/libs/mapsyncer-paper-<version>.jar
```
CI (`.gitea/workflows/build.yml`) builds on every push and attaches the jar to
a release for every `v*` tag.
## Licence
GPL-3.0. The Xaero region writer and MCA parser are vendored from MapSyncer
(GPL-3.0); see `NOTICE.md` for the exact provenance and the local changes.
+89
View File
@@ -0,0 +1,89 @@
plugins {
`java-library`
id("io.papermc.paperweight.userdev") version "2.0.0-beta.23"
id("com.gradleup.shadow") version "9.6.1"
}
group = property("group") as String
version = property("version") as String
val javaRelease = (property("javaRelease") as String).toInt()
val paperDevBundleVersion = property("paperDevBundleVersion") as String
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
}
dependencies {
paperweight.paperDevBundle(paperDevBundleVersion)
// Region files written by 1.21.2+ servers may be LZ4-compressed. The
// server bundles lz4-java (Paper's at.yawk.lz4 fork, same net.jpountz
// package) and the dev bundle exposes it, so nothing is shaded for it.
// slf4j-api is likewise provided by the server (log4j-slf4j2-impl).
compileOnly("org.slf4j:slf4j-api:2.0.18")
testImplementation(platform("org.junit:junit-bom:6.1.1"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
// Paper has been Mojang-mapped at runtime since 1.20.5, so the compiled jar is
// used as-is; no reobfuscation step. The manifest attribute below tells the
// server not to try remapping it either.
paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArtifactConfiguration.MOJANG_PRODUCTION
// No toolchain block on purpose (same reasoning as geoblock-limiter): any JDK
// >= javaRelease builds it, and --release pins the class-file level.
java {
withSourcesJar()
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(javaRelease)
options.encoding = "UTF-8"
// Warnings are informative, not fatal: a dev-bundle bump routinely
// deprecates something and must not break a routine dependency update.
options.compilerArgs.add("-Xlint:all,-serial,-processing")
}
tasks.processResources {
val props = mapOf("version" to project.version.toString())
inputs.properties(props)
filesMatching("plugin.yml") { expand(props) }
}
tasks.jar {
// The thin jar is an intermediate; the shaded one is the deliverable.
archiveClassifier.set("thin")
}
tasks.shadowJar {
archiveBaseName.set("mapsyncer-paper")
archiveClassifier.set("")
// Nothing is bundled today; the shadow task exists so a future dependency
// can be added without changing the CI or the deploy path.
manifest {
attributes("paperweight-mappings-namespace" to "mojang")
}
}
tasks.assemble {
dependsOn(tasks.shadowJar)
}
tasks.test {
useJUnitPlatform()
// Optional: -Pmapsyncer.test.mca=/path/to/r.0.0.mca runs the conversion
// test against a real region file; without it that test is skipped.
val realMca = providers.gradleProperty("mapsyncer.test.mca").orNull
if (realMca != null) {
systemProperty("mapsyncer.test.mca", realMca)
inputs.file(realMca)
}
testLogging {
events("failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
+16
View File
@@ -0,0 +1,16 @@
group = lv.janhouse
version = 0.1.0
# Paper dev bundle (Mojang-mapped server + API) the plugin compiles against.
# Must match the server it is deployed on: Paper 26.2 on JDK 25. The renderer
# needs a handful of server internals (block classes, render shape, tags), which
# is why this is a dev bundle and not plain paper-api.
paperDevBundleVersion = 26.2.build.121-stable
# Class-file target. The server container runs OpenJDK 25, so the jar must not
# be compiled for anything newer; --release also fences off newer JDK APIs.
javaRelease = 25
org.gradle.jvmargs = -Xmx2g
org.gradle.parallel = true
org.gradle.caching = true
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+8
View File
@@ -0,0 +1,8 @@
pluginManagement {
repositories {
gradlePluginPortal()
maven("https://repo.papermc.io/repository/maven-public/")
}
}
rootProject.name = "mapsyncer-paper"
@@ -0,0 +1,114 @@
package com.mapsyncer;
import com.mapsyncer.mca.RegionConverterStandalone.ConvertedRegion;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.zip.CheckedOutputStream;
import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Xaero地图文件写入器 - 将转换后的区域数据写入Xaero兼容的zip文件
*
* 输出格式:{outputDir}/{regionX}_{regionZ}.zip,包含一个"region.xaero"条目。
* 使用临时文件+原子替换的方式写入,确保文件完整性。
*/
public class XaeroWriter {
private static final Logger LOGGER = LoggerFactory.getLogger(XaeroWriter.class);
/** 超过此时间的 .temp 残留文件视为可安全删除(毫秒) */
private static final long TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
/**
* 清理残留的 .zip.temp 文件。
*
* <p>正常写入通过原子 rename 完成,JVM 崩溃后 .temp 文件会残留。
* 此方法删除超过 {@link #TEMP_FILE_MAX_AGE_MS} 的残留文件,
* 确保不会误删正在进行的写入。</p>
*
* @param rootDir 缓存根目录(会递归扫描)
* @return 清理的文件数量
*/
public static int cleanStaleTempFiles(Path rootDir) {
if (!Files.exists(rootDir)) return 0;
long cutoff = System.currentTimeMillis() - TEMP_FILE_MAX_AGE_MS;
int[] count = {0};
try (var stream = Files.walk(rootDir)) {
stream.filter(p -> p.getFileName().toString().endsWith(".zip.temp"))
.forEach(p -> {
try {
if (Files.getLastModifiedTime(p).toMillis() < cutoff) {
Files.deleteIfExists(p);
count[0]++;
LOGGER.debug("Cleaned stale temp file: {}", p);
}
} catch (IOException ignored) {
// 文件可能已被其他进程删除
}
});
} catch (IOException e) {
LOGGER.warn("Failed to scan for stale temp files in {}", rootDir, e);
}
if (count[0] > 0) {
LOGGER.info("Cleaned {} stale .temp files from {}", count[0], rootDir);
}
return count[0];
}
/**
* 区域 zip 写入结果(路径 + 写入时计算的 CRC32,与 {@link com.mapsyncer.util.HashUtils} 读盘结果一致)。
*/
public record RegionWriteResult(Path path, String crc32Hash) {}
/**
* 将转换后的区域数据写入 zip 文件,并在单次写盘中计算 CRC32。
*
* @param outputDir 输出目录路径
* @param region 转换后的区域数据
* @return 最终文件路径与 CRC32 哈希(8 位十六进制)
* @throws IOException 如果写入过程中发生 IO 错误
*/
public static RegionWriteResult writeRegionFile(Path outputDir, ConvertedRegion region) throws IOException {
Files.createDirectories(outputDir);
String fileName = region.regionX() + "_" + region.regionZ();
Path tempFile = outputDir.resolve(fileName + ".zip.temp");
Path finalFile = outputDir.resolve(fileName + ".zip");
CRC32 crc32 = new CRC32();
try (OutputStream fileOut = Files.newOutputStream(tempFile);
CheckedOutputStream checkedOut = new CheckedOutputStream(fileOut, crc32);
ZipOutputStream zos = new ZipOutputStream(checkedOut)) {
ZipEntry entry = new ZipEntry("region.xaero");
zos.putNextEntry(entry);
zos.write(region.xaeroData());
zos.closeEntry();
}
Files.move(tempFile, finalFile, StandardCopyOption.REPLACE_EXISTING);
return new RegionWriteResult(finalFile, String.format("%08x", crc32.getValue()));
}
/**
* 检查区域文件是否已存在
*
* @param outputDir 输出目录路径
* @param regionX 区域X坐标
* @param regionZ 区域Z坐标
* @return true表示文件存在,false表示不存在
*/
public static boolean regionFileExists(Path outputDir, int regionX, int regionZ) {
Path zipFile = outputDir.resolve(regionX + "_" + regionZ + ".zip");
return Files.exists(zipFile);
}
}
@@ -0,0 +1,59 @@
package com.mapsyncer.mca;
import java.util.Map;
/**
* 方块属性查询接口 - 用于解耦 MCA 转换器与平台特定的方块注册表
*
* <p>各平台模块(Fabric/NeoForge)通过实现此接口,
* 将 Minecraft 运行时的方块属性查询暴露给通用的 MCA 转换器。</p>
*/
public interface BlockPropertyLookup {
// flags bit definitions
int FLAG_WATER = 1;
int FLAG_TRANSPARENT = 2;
int FLAG_INVISIBLE = 4;
int FLAG_SHOULD_OVERLAY = 8;
int FLAG_HAS_VANILLA_COLOR = 16;
int FLAG_GLOWING = 32;
int FLAG_TRANSLUCENT_FLUID = 64;
int FLAG_WATER_INHERITING = 128;
/**
* 批量查询方块属性标志(位掩码),避免多次 HashMap 查询。
* @return 按上述 FLAG_* 定义的位掩码
*/
int getFlags(String blockName);
boolean isWater(String blockName);
boolean isTransparent(String blockName);
boolean isInvisible(String blockName);
boolean shouldOverlay(String blockName);
boolean hasVanillaColor(String blockName);
boolean isGrassBlock(String blockName);
boolean isGlowing(String blockName);
boolean isTranslucentFluid(String blockName);
boolean isWaterloggedSurface(String blockName, Map<String, String> properties);
/**
* 判断方块是否为水生植物(Water-inheriting
*
* <p>水生植物(海草、海带等)在水中生长,但 NBT 中不存储 waterlogged 属性。
* 它们应继承上方水体的 overlay,渲染为:水 overlay + 植物表面。</p>
*
* @param blockName 方块注册表名称
* @return true 表示是水生植物
*/
boolean isWaterInheriting(String blockName);
int getLightBlock(String blockName);
}
@@ -0,0 +1,535 @@
package com.mapsyncer.mca;
import com.mapsyncer.mca.convert.biome.BiomeQuartGrid;
import com.mapsyncer.nbt.Tag;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Chunk数据解析器
*
* <p>解析完整的chunk NBT数据,包括:</p>
* <ul>
* <li>高度图数据(用于地表模式扫描)</li>
* <li>区块状态验证(只处理已生成地形的区块)</li>
* <li>所有Section的方块和生物群系数据</li>
* <li>光照数据的查询和计算</li>
* </ul>
*
* <p>支持1.18+格式(flat root)和旧格式(nested under "Level"</p>
*
* @see ChunkSectionParser 用于解析单个Section的数据
* @see ChunkInfo Chunk数据信息记录
* @see LightStats 光照统计信息记录
*/
public class ChunkDataParser {
/**
* 可接受的区块状态集合
*
* <p>参考 Xaero WorldDataReader: 接受 >= FEATURES 状态的区块</p>
*
* <p>ChunkStatus 顺序:</p>
* <pre>empty -> structure_starts -> structure_references -> biomes -> noise -> surface -> features -> light -> spawn -> heightmaps -> full</pre>
*
* <p>Xaero 在 WorldDataReader.java 第333-340行:</p>
* <ul>
* <li>chunkStatusIndex < BIOMES.getIndex() → return false(跳过)</li>
* <li>handleChunkBiomes() 处理生物群系数据</li>
* <li>chunkStatusIndex < FEATURES.getIndex() → return false(跳过)</li>
* </ul>
*
* <p>所以 Xaero 接受 >= FEATURES 的状态,包括:</p>
* <ul>features, light, spawn, heightmaps, full</ul>
*/
private static final Set<String> ACCEPTABLE_STATUSES = Set.of(
"minecraft:features",
"minecraft:initialize_light",
"minecraft:light",
"minecraft:spawn",
"minecraft:heightmaps",
"minecraft:full",
// 不带命名空间的简写
"features",
"initialize_light",
"light",
"spawn",
"heightmaps",
"full"
);
/**
* 判断区块状态是否应该跳过
*
* <p>参考 Xaero WorldDataReader: 接受 >= FEATURES 状态的区块</p>
*
* @param status 区块状态字符串
* @return true 表示跳过该区块,false 表示保留该区块
*/
private static boolean shouldSkipChunk(String status) {
if (status == null || status.isEmpty()) {
return true; // 无状态的区块跳过
}
// 处理带命名空间和不带命名空间的状态
String normalizedStatus = status.contains(":") ? status : "minecraft:" + status;
// 接受 >= FEATURES 状态的区块
return !ACCEPTABLE_STATUSES.contains(normalizedStatus);
}
/**
* Chunk数据信息记录
*
* <p>存储解析后的完整Chunk数据</p>
*
* @param chunkX Chunk的局部坐标 (0-31)
* @param chunkZ Chunk的局部坐标 (0-31)
* @param yPos Chunk底部Y坐标参数(yPos * 16 = chunkBottomY
* @param chunkBottomY Chunk底部世界Y坐标
* @param status Chunk状态字符串(如 "minecraft:full"
* @param heightmap 高度图数组(16x16,存储世界绝对Y坐标)
* @param sectionLookup O(1) section 查询数组
* @param biomeGrid 预计算的 quart biome 查表
*/
public record ChunkInfo(
int chunkX, // Chunk的局部坐标 (0-31)
int chunkZ,
int yPos, // Chunk底部Y坐标 (yPos * 16)
int chunkBottomY, // Chunk底部世界Y坐标
String status, // Chunk状态 ("minecraft:full", ...)
int[][] heightmap, // 高度图 (16x16, 世界绝对Y坐标)
List<ChunkSectionParser.SectionData> sections,
int minSectionY, // 最底section的Y,用于sectionLookup偏移
ChunkSectionParser.SectionData[] sectionLookup, // O(1) section查询
BiomeQuartGrid biomeGrid
) {}
/**
* 解析chunk NBT数据
*
* <p>支持1.18+格式(flat root)和旧格式(nested under "Level"</p>
*
* <p>处理流程:</p>
* <ol>
* <li>检查区块状态,跳过未生成地形的区块</li>
* <li>解析高度图数据</li>
* <li>解析所有Section数据并按Y坐标排序</li>
* </ol>
*
* @param localX chunk本地X坐标 (0-31)
* @param localZ chunk本地Z坐标 (0-31)
* @param chunkNbt chunk NBT数据
* @param worldHeightRange 维度高度范围(worldTopY - minBuildHeight
* @return ChunkInfo对象,如果区块无效则返回null
*/
public static ChunkInfo parseChunk(int localX, int localZ, Tag.Compound chunkNbt, int worldHeightRange) {
// 检查chunk状态 - 只处理已生成地形的区块
// 区块生成顺序:empty -> structure_starts -> structure_references -> biomes -> noise -> surface -> ...
// 只有 surface 及之后的状态才有实际地形数据
String status = chunkNbt.getString("Status");
// 跳过未生成地形的早期状态
if (shouldSkipChunk(status)) {
return null;
}
// 1.18+格式检查:sections在根层级
Tag.Compound rootTag;
if (chunkNbt.contains("sections", Tag.TAG_LIST)) {
rootTag = chunkNbt;
} else if (chunkNbt.contains("Level", Tag.TAG_COMPOUND)) {
// 旧格式:数据嵌套在Level下
rootTag = chunkNbt.getCompound("Level");
} else {
return null; // 无法识别的格式
}
// 解析yPos (1.18+)
int yPos = chunkNbt.getInt("yPos");
int chunkBottomY = yPos * 16;
// 解析高度图(传入维度高度范围)
int[][] heightmap = parseHeightmap(rootTag, chunkBottomY, worldHeightRange);
// 解析sections
List<ChunkSectionParser.SectionData> sections = new ArrayList<>();
if (rootTag.contains("sections", Tag.TAG_LIST)) {
Tag.ListTag sectionsList = rootTag.getList("sections", Tag.TAG_COMPOUND);
for (int i = 0; i < sectionsList.items().size(); i++) {
Tag.Compound sectionTag = (Tag.Compound) sectionsList.items().get(i);
ChunkSectionParser.SectionData section = ChunkSectionParser.parseSection(sectionTag);
sections.add(section);
}
}
// 按Y坐标从高到低排序(用于从上到下扫描)
sections.sort((a, b) -> Integer.compare(b.sectionY(), a.sectionY()));
// 构建 O(1) section lookup 数组
int minSectionY = 0;
ChunkSectionParser.SectionData[] sectionLookup = null;
if (!sections.isEmpty()) {
int maxY = sections.get(0).sectionY();
minSectionY = sections.get(sections.size() - 1).sectionY();
int range = maxY - minSectionY + 1;
sectionLookup = new ChunkSectionParser.SectionData[range];
for (ChunkSectionParser.SectionData sec : sections) {
int idx = sec.sectionY() - minSectionY;
if (idx >= 0 && idx < range) sectionLookup[idx] = sec;
}
}
BiomeQuartGrid biomeGrid = BiomeQuartGrid.build(sections, minSectionY, sectionLookup);
return new ChunkInfo(localX, localZ, yPos, chunkBottomY, status, heightmap, sections, minSectionY, sectionLookup, biomeGrid);
}
/**
* 解析高度图数据
*
* <p>支持多种格式:</p>
* <ul>
* <li>1.18+ WORLD_SURFACE (LongArray)</li>
* <li>MOTION_BLOCKING_NO_LEAVES(包括水方块)</li>
* <li>旧版 HeightMap (IntArray)</li>
* </ul>
*
* <p>优先使用 MOTION_BLOCKING_NO_LEAVES,能正确检测上方的水方块</p>
*
* @param rootTag chunk根NBT数据
* @param chunkBottomY chunk最低Y坐标
* @param worldHeightRange 维度高度范围(用于计算bitsPerHeight
* @return 16x16的高度图数组(存储世界绝对Y坐标)
*/
private static int[][] parseHeightmap(Tag.Compound rootTag, int chunkBottomY, int worldHeightRange) {
int[][] heightmap = new int[16][16];
// 尝试新格式 Heightmaps
if (rootTag.contains("Heightmaps", Tag.TAG_COMPOUND)) {
Tag.Compound heightmaps = rootTag.getCompound("Heightmaps");
// 优先使用 WORLD_SURFACE(与 Xaero 一致,树冠计入高度,树叶渲染更饱满)
if (heightmaps.contains("WORLD_SURFACE", Tag.TAG_LONG_ARRAY)) {
long[] data = heightmaps.getLongArray("WORLD_SURFACE");
int bitsPerHeight = calculateBitsPerHeight(data.length, worldHeightRange);
if (bitsPerHeight > 0 && bitsPerHeight <= 10) {
decodeHeightmapLongArray(data, bitsPerHeight, chunkBottomY, heightmap);
return heightmap;
}
}
// 备用 MOTION_BLOCKING_NO_LEAVES
if (heightmaps.contains("MOTION_BLOCKING_NO_LEAVES", Tag.TAG_LONG_ARRAY)) {
long[] data = heightmaps.getLongArray("MOTION_BLOCKING_NO_LEAVES");
int bitsPerHeight = calculateBitsPerHeight(data.length, worldHeightRange);
if (bitsPerHeight > 0 && bitsPerHeight <= 10) {
decodeHeightmapLongArray(data, bitsPerHeight, chunkBottomY, heightmap);
return heightmap;
}
}
}
// 旧格式 HeightMap (IntArray)
if (rootTag.contains("HeightMap", Tag.TAG_INT_ARRAY)) {
int[] data = rootTag.getIntArray("HeightMap");
if (data.length == 256) {
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
heightmap[x][z] = data[z * 16 + x]; // 直接是世界绝对Y坐标
}
}
return heightmap;
}
}
// 无高度图数据,返回默认值
return heightmap;
}
/**
* 计算高度图的bitsPerEntry
*
* <p>根据Wiki公式反推:</p>
* <ul>
* <li>h = 最高高度 - 最低建筑高度(维度高度范围)</li>
* <li>b = ceil(log2(h))</li>
* <li>u = floor(64/b)</li>
* <li>l = ceil(256/u)</li>
* </ul>
*
* @param longArrayLength LongArray的长度
* @param worldHeightRange 维度高度范围
* @return 每个高度值的位数b
*/
private static int calculateBitsPerHeight(int longArrayLength, int worldHeightRange) {
// 优先使用维度高度范围计算(更准确)
if (worldHeightRange > 0) {
// Vanilla: Mth.ceillog2(height + 1). The upstream ceil(log2(h)) form
// under-counts by one bit for power-of-two heights (Nether/End, 256).
return 32 - Integer.numberOfLeadingZeros(worldHeightRange);
}
// 备用:从数组长度反推
// l = ceil(256/u) => u ≈ ceil(256/l)
// b = floor(64/u)
if (longArrayLength <= 0) return 0;
int u = (256 + longArrayLength - 1) / longArrayLength; // ceil(256/l)
return 64 / u;
}
/**
* 解码LongArray高度图(Wiki规范)
*
* <p>存储的是相对于维度最低建筑高度的偏移量</p>
*
* <p>Wiki公式:</p>
* <ul>
* <li>编码序号 i = x + 16*z</li>
* <li>值 = (data[i/u] >> ((i%u)*b)) & ((1L<<b)-1L) + low</li>
* </ul>
*
* @param data long数组
* @param bitsPerHeight 每个高度值的位数b
* @param chunkBottomY chunk的最低Y坐标(作为基线)
* @param heightmap 输出高度图数组
*/
private static void decodeHeightmapLongArray(long[] data, int bitsPerHeight, int chunkBottomY, int[][] heightmap) {
if (data == null || data.length == 0 || bitsPerHeight <= 0) {
return;
}
// u = floor(64/b)
int u = 64 / bitsPerHeight;
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
// Wiki: i = x + 16*z
int i = x + 16 * z;
// Wiki公式:值 = (data[i/u] >> ((i%u)*b)) & ((1L<<b)-1L)
int longIndex = i / u;
int bitOffset = (i % u) * bitsPerHeight;
if (longIndex >= data.length) {
heightmap[x][z] = chunkBottomY;
continue;
}
long rawValue = (data[longIndex] >>> bitOffset) & ((1L << bitsPerHeight) - 1L);
// 世界绝对Y = chunkBottomY + 偏移量(chunkBottomY作为基线)
heightmap[x][z] = chunkBottomY + (int) rawValue;
}
}
}
/**
* 从chunk sections中获取指定位置的方块状态(完整信息)
*
* @param chunk Chunk数据
* @param x 局部X坐标 (0-15)
* @param worldY 世界Y坐标
* @param z 局部Z坐标 (0-15)
* @return 方块状态对象
*/
public static ChunkSectionParser.BlockState getBlockStateAt(ChunkInfo chunk, int x, int worldY, int z) {
int sectionY = worldY >> 4;
int localY = worldY & 0xF;
ChunkSectionParser.SectionData[] lookup = chunk.sectionLookup();
if (lookup != null) {
int idx = sectionY - chunk.minSectionY();
if (idx >= 0 && idx < lookup.length && lookup[idx] != null) {
return ChunkSectionParser.getBlockStateAt(lookup[idx], x, localY, z);
}
}
return new ChunkSectionParser.BlockState("minecraft:air", Map.of());
}
/**
* 从chunk sections中获取指定位置的生物群系(默认启用边界平滑)
*
* @param chunk Chunk数据
* @param x 局部X坐标 (0-15)
* @param worldY 世界Y坐标
* @param z 局部Z坐标 (0-15)
* @return 生物群系名称字符串
*/
public static String getBiomeAt(ChunkInfo chunk, int x, int worldY, int z) {
return getBiomeAt(chunk, x, worldY, z, false);
}
/**
* 从chunk sections中获取指定位置的生物群系(可选择是否启用边界平滑)
*
* @param chunk Chunk数据
* @param x 局部X坐标 (0-15)
* @param worldY 世界Y坐标
* @param z 局部Z坐标 (0-15)
* @param smoothBoundary 是否启用voxel边界平滑
* @return 生物群系名称字符串
*/
public static String getBiomeAt(ChunkInfo chunk, int x, int worldY, int z, boolean smoothBoundary) {
int sectionY = worldY >> 4;
int localY = worldY & 0xF;
ChunkSectionParser.SectionData[] lookup = chunk.sectionLookup();
if (lookup != null) {
int idx = sectionY - chunk.minSectionY();
if (idx >= 0 && idx < lookup.length && lookup[idx] != null) {
return ChunkSectionParser.getBiomeAt(lookup[idx], x, localY, z, smoothBoundary);
}
}
return null;
}
/**
* 获取chunk中指定位置的方块光照
*
* @param chunk Chunk数据
* @param x 局部X坐标 (0-15)
* @param worldY 世界Y坐标
* @param z 局部Z坐标 (0-15)
* @return 方块光照值 (0-15)
*/
public static byte getBlockLightAt(ChunkInfo chunk, int x, int worldY, int z) {
int sectionY = worldY >> 4;
int localY = worldY & 0xF;
for (ChunkSectionParser.SectionData section : chunk.sections()) {
if (section.sectionY() == sectionY) {
return ChunkSectionParser.getBlockLight(section, x, localY, z);
}
}
return 0;
}
/**
* 获取chunk中指定位置的天空光照
*
* @param chunk Chunk数据
* @param x 局部X坐标 (0-15)
* @param worldY 世界Y坐标
* @param z 局部Z坐标 (0-15)
* @return 天空光照值 (0-15)
*/
public static byte getSkyLightAt(ChunkInfo chunk, int x, int worldY, int z) {
int sectionY = worldY >> 4;
int localY = worldY & 0xF;
for (ChunkSectionParser.SectionData section : chunk.sections()) {
if (section.sectionY() == sectionY) {
return ChunkSectionParser.getSkyLight(section, x, localY, z);
}
}
return 0;
}
/**
* 判断指定位置是否有天空访问(用于光照计算)
*
* <p>基于高度图判断:如果worldY >= heightmap[x][z],则有天空访问</p>
*
* @param chunk Chunk数据
* @param x 局部X坐标 (0-15)
* @param worldY 世界Y坐标
* @param z 局部Z坐标 (0-15)
* @return 如果位置高于高度图则返回true
*/
public static boolean hasSkyAccess(ChunkInfo chunk, int x, int worldY, int z) {
int surfaceY = chunk.heightmap()[x][z];
return worldY >= surfaceY;
}
/**
* 获取有效光照值(支持光照模式)
*
* <p>根据光照模式和维度属性计算最终的有效光照</p>
*
* @param chunk Chunk数据
* @param x 局部X坐标 (0-15)
* @param worldY 世界Y坐标
* @param z 局部Z坐标 (0-15)
* @param lightMode 光照模式(SURFACE 或 CAVE
* @param hasOverlay 是否有覆盖层(水、玻璃等透明方块)
* @param worldHasSkylight 维度是否有天空光照
* @return 有效光照值 (0-15)
*/
public static byte getEffectiveLight(ChunkInfo chunk, int x, int worldY, int z,
LightMode lightMode, boolean hasOverlay,
boolean worldHasSkylight) {
byte blockLight = getBlockLightAt(chunk, x, worldY, z);
byte skyLight = getSkyLightAt(chunk, x, worldY, z);
boolean hasSkyAccess = hasSkyAccess(chunk, x, worldY, z);
return lightMode.calculateEffectiveLight(blockLight, skyLight, hasSkyAccess, hasOverlay, false, worldHasSkylight);
}
/**
* 获取洞穴模式有效光照
*
* <p>参考 Xaero WorldDataReader 的洞穴模式光照逻辑:</p>
* <ul>
* <li>BlockLight >= 15 时直接返回(发光方块)</li>
* <li>有天空访问且无 overlay 时返回 15(直接日照)</li>
* <li>无 overlay 时取 max(BlockLight, SkyLight)</li>
* <li>有 overlay 时使用 BlockLight(水下场景)</li>
* </ul>
*
* @param chunk Chunk数据
* @param x 局部X坐标 (0-15)
* @param worldY 世界Y坐标
* @param z 局部Z坐标 (0-15)
* @param hasOverlay 是否有覆盖层
* @return 有效光照值 (0-15)
*/
public static byte getEffectiveLightCave(ChunkInfo chunk, int x, int worldY, int z,
boolean hasOverlay) {
byte blockLight = getBlockLightAt(chunk, x, worldY, z);
// BlockLight >= 15 时直接返回(发光方块)
if (blockLight >= 15) {
return blockLight;
}
boolean hasSkyAccess = hasSkyAccess(chunk, x, worldY, z);
// 有天空访问且无 overlay 时返回 15
if (hasSkyAccess && !hasOverlay) {
return 15;
}
// 无 overlay 时取 max(BlockLight, SkyLight)
if (!hasOverlay) {
byte skyLight = getSkyLightAt(chunk, x, worldY, z);
return (byte) Math.max(blockLight, skyLight);
}
// 有 overlay 时使用 BlockLight
return blockLight;
}
/**
* 获取高度图值(带+3容差)
*
* <p>+3容差用于覆盖草方块上的草/花/雪层等装饰方块</p>
*
* @param chunk Chunk数据
* @param x 局部X坐标 (0-15)
* @param z 局部Z坐标 (0-15)
* @param worldTopY 世界顶部Y坐标限制
* @return 扫描起始高度(不超过世界顶部)
*/
public static int getHeightmapStartY(ChunkInfo chunk, int x, int z, int worldTopY) {
int heightMapValue = chunk.heightmap()[x][z];
// +3容差(覆盖草方块上的草/花/雪层)
int startY = heightMapValue + 3;
// 不能超过世界顶部
return Math.min(startY, worldTopY - 1);
}
}
@@ -0,0 +1,23 @@
package com.mapsyncer.mca;
/**
* Selects which chunks of a region take part in a conversion.
*
* <p>Chunks the mask rejects are treated exactly like chunks that do not exist
* in the region file: the Xaero region gets a "no data" (-1) tile for them, so a
* partially discovered region can be rendered without inventing terrain nobody
* has seen. (mapsyncer-paper addition; not in upstream MapSyncer.)
*/
@FunctionalInterface
public interface ChunkMask {
/** Every chunk present in the file is converted. */
ChunkMask ALL = (localX, localZ) -> true;
/**
* @param localX chunk X inside the region, 0..31
* @param localZ chunk Z inside the region, 0..31
* @return true if the chunk may be rendered
*/
boolean includes(int localX, int localZ);
}
@@ -0,0 +1,836 @@
package com.mapsyncer.mca;
import com.mapsyncer.nbt.Tag;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Chunk Section数据解析器
*
* <p>解析单个section的方块和生物群系数据,包括:</p>
* <ul>
* <li>方块状态调色板和位数组</li>
* <li>生物群系调色板和位数组</li>
* <li>方块光照和天空光照数据</li>
* </ul>
*
* <p>Section是16x16x16的方块区域,一个完整的Chunk由多个Section组成</p>
*
* @see ChunkDataParser 用于解析完整的Chunk数据
* @see SectionData Section数据记录
* @see BlockState 方块状态记录
*/
public class ChunkSectionParser {
/**
* 花朵方块名称集合
*
* <p>使用 Set 常量替代多次字符串比较,提高判断效率。</p>
*/
private static final Set<String> FLOWER_NAMES = Set.of(
"minecraft:dandelion", "minecraft:poppy",
"minecraft:blue_orchid", "minecraft:allium",
"minecraft:red_tulip", "minecraft:orange_tulip",
"minecraft:white_tulip", "minecraft:pink_tulip",
"minecraft:oxeye_daisy", "minecraft:cornflower",
"minecraft:lily_of_the_valley", "minecraft:wither_rose",
"minecraft:sunflower", "minecraft:rose_bush",
"minecraft:peony", "minecraft:azure_bluet",
"minecraft:pitcher_plant"
);
/**
* 方块状态数据记录(包含名称和属性)
*
* <p>存储方块的基本信息和所有状态属性</p>
*
* @param name 方块名称(如 "minecraft:stone"
* @param properties 方块属性映射(如 {snowy: "false", facing: "north"}
*/
public record BlockState(
String name, // 方块名称 "minecraft:stone"
Map<String, String> properties // 属性 {snowy: "false", facing: "north"}
) {
/** 无属性方块的共享空 map,避免 parse 时重复分配。 */
public static final Map<String, String> EMPTY_PROPERTIES = Map.of();
/**
* 获取完整方块ID(带属性)
*
* <p>格式: "minecraft:grass_block[snowy=false]"</p>
*
* @return 包含属性的完整方块标识字符串
*/
public String getFullName() {
if (properties.isEmpty()) {
return name;
}
StringBuilder sb = new StringBuilder(name);
sb.append("[");
boolean first = true;
for (Map.Entry<String, String> e : properties.entrySet()) {
if (!first) sb.append(",");
sb.append(e.getKey()).append("=").append(e.getValue());
first = false;
}
sb.append("]");
return sb.toString();
}
/**
* 判断是否为空气方块
*
* <p>包括所有类型的空气方块(air, cave_air, void_air</p>
*
* @return 如果方块名称包含"air"则返回true
*/
public boolean isAir() {
return name.equals("minecraft:air") ||
name.equals("minecraft:cave_air") ||
name.equals("minecraft:void_air");
}
/**
* 判断是否为水方块
*
* <p>包括静态水和流动水</p>
*
* @return 如果方块名称是"minecraft:water"或"minecraft:flowing_water"则返回true
*/
public boolean isWater() {
return name.equals("minecraft:water") || name.equals("minecraft:flowing_water");
}
/**
* 判断是否为熔岩方块
*
* <p>包括静态熔岩和流动熔岩</p>
*
* @return 如果方块名称是"minecraft:lava"或"minecraft:flowing_lava"则返回true
*/
public boolean isLava() {
return name.equals("minecraft:lava") || name.equals("minecraft:flowing_lava");
}
/**
* 判断是否为流体方块(水或熔岩)
*
* @return 如果是水或熔岩则返回true
*/
public boolean isFluid() {
return isWater() || isLava();
}
/**
* 判断是否为草方块
*
* @return 如果方块名称是"minecraft:grass_block"则返回true
*/
public boolean isGrassBlock() {
return name.equals("minecraft:grass_block");
}
/**
* 判断是否为透明方块(水、熔岩、玻璃等)
*
* <p>用于决定是否作为 overlay 处理</p>
*
* @return 如果方块是透明覆盖层类型则返回true
*/
public boolean isTransparentOverlay() {
return isWater() || name.equals("minecraft:glass") ||
name.endsWith("_stained_glass") || name.equals("minecraft:glass_pane") ||
name.endsWith("_stained_glass_pane") || name.equals("minecraft:ice") ||
name.endsWith("_ice") || name.equals("minecraft:tinted_glass");
}
/**
* 判断是否为隐形方块(扫描时跳过)
*
* <p>包括: torch, short_grass, flowers, glass 等</p>
*
* @return 如果方块应该被跳过则返回true
*/
public boolean isInvisible() {
// Torch
if (name.equals("minecraft:torch") || name.endsWith("_torch")) return true;
// Short grass
if (name.equals("minecraft:short_grass") || name.equals("minecraft:grass")) return true;
// Glass (handled as transparent overlay, not invisible in scan)
// Flowers (default flowers config off)
if (isFlower()) return true;
// Double plant non-flowers (tall_grass, large_fern)
if (name.equals("minecraft:tall_grass") || name.equals("minecraft:large_fern")) return true;
return false;
}
/**
* 判断是否为花朵方块
*
* <p>包括单层花和双层花</p>
*
* @return 如果方块是花朵类型则返回true
*/
public boolean isFlower() {
return FLOWER_NAMES.contains(name) ||
name.endsWith("_tulip") ||
name.contains("orchid") ||
name.endsWith("_pitcher_crop");
}
/**
* 判断是否为含水方块(waterlogged=true
*
* <p>含水方块同时包含方块本身和水</p>
*
* @return 如果属性中有waterlogged=true则返回true
*/
public boolean isWaterlogged() {
return properties.containsKey("waterlogged") &&
"true".equals(properties.get("waterlogged"));
}
/**
* 判断方块是否为表面方块且上方有水(含水方块)
*
* <p>含水方块:方块本身有颜色,但上方应该渲染水 overlay</p>
*
* @return 如果方块是含水且不是纯水方块则返回true
*/
public boolean isWaterloggedSurface() {
return isWaterlogged() && !isWater() && !isAir();
}
}
/**
* Section数据记录
*
* <p>存储单个Section16x16x16方块区域)的所有数据</p>
*
* @param sectionY Section的世界Y坐标(sectionY * 16 = section基线Y
* @param blockPalette 方块状态列表(包含完整属性)
* @param blockNames 方块名称列表(仅名称,用于快速查询)
* @param blockData 位压缩的方块索引数据
* @param blockBitsPerEntry 每个方块索引的位数
* @param biomePalette 生物群系名称列表(如 ["minecraft:plains", ...]
* @param biomeData 位压缩的生物群系索引数据
* @param biomeBitsPerEntry 每个生物群系索引的位数
* @param blockLight 方块光照数组(2048字节)
* @param skyLight 天空光照数组(2048字节)
*/
public record SectionData(
int sectionY, // Section的世界Y坐标 (sectionY * 16 = section基线Y)
List<BlockState> blockPalette, // 方块状态列表(包含属性)
List<String> blockNames, // 方块名称列表(仅名称,用于快速查询)
long[] blockData, // 位压缩的方块索引数据
int blockBitsPerEntry, // 每个方块索引的位数
List<String> biomePalette, // 生物群系名称列表 ["minecraft:plains", ...]
long[] biomeData, // 位压缩的生物群系索引数据
int biomeBitsPerEntry, // 每个生物群系索引的位数
byte[] blockLight, // 预解码的方块光照 (4096字节,预热使用)
byte[] skyLight, // 预解码的天空光照 (4096字节,预热使用)
int blockUVal, // floor(64 / blockBitsPerEntry),预计算
long blockMask // (1L << blockBitsPerEntry) - 1,预计算
) {
// record 自动提供 blockNames() 访问方法,无需额外定义
}
/**
* 从NBT复合标签解析Section数据
*
* <p>解析方块状态、生物群系和光照数据</p>
*
* @param sectionTag Section的NBT复合标签
* @return 解析后的SectionData对象
*/
public static SectionData parseSection(Tag.Compound sectionTag) {
int sectionY = sectionTag.getByte("Y");
// 解析block_states
List<BlockState> blockPalette = new ArrayList<>();
List<String> blockNames = new ArrayList<>();
long[] blockData = null;
int blockBitsPerEntry = 0;
if (sectionTag.contains("block_states", Tag.TAG_COMPOUND)) {
Tag.Compound blockStates = sectionTag.getCompound("block_states");
// 解析palette(包含完整属性)
if (blockStates.contains("palette", Tag.TAG_LIST)) {
Tag.ListTag paletteList = blockStates.getList("palette", Tag.TAG_COMPOUND);
for (int i = 0; i < paletteList.items().size(); i++) {
Tag.Compound stateTag = (Tag.Compound) paletteList.items().get(i);
BlockState blockState = parseBlockState(stateTag);
blockPalette.add(blockState);
blockNames.add(blockState.name());
}
}
// 解析data
if (blockStates.contains("data", Tag.TAG_LONG_ARRAY)) {
blockData = blockStates.getLongArray("data");
}
// 计算bitsPerEntry
blockBitsPerEntry = calculateBitsPerEntry(blockPalette.size(), blockData);
}
// 解析biomes
List<String> biomePalette = new ArrayList<>();
long[] biomeData = null;
int biomeBitsPerEntry = 0;
if (sectionTag.contains("biomes", Tag.TAG_COMPOUND)) {
Tag.Compound biomes = sectionTag.getCompound("biomes");
// 解析palette (biome palette元素是String类型)
if (biomes.contains("palette", Tag.TAG_LIST)) {
Tag.ListTag paletteList = biomes.getList("palette", Tag.TAG_STRING);
for (int i = 0; i < paletteList.items().size(); i++) {
Tag.StringTag biomeTag = (Tag.StringTag) paletteList.items().get(i);
biomePalette.add(biomeTag.value());
}
}
// 解析data
if (biomes.contains("data", Tag.TAG_LONG_ARRAY)) {
biomeData = biomes.getLongArray("data");
}
// 计算bitsPerEntry - biome使用64个voxel4x4x4),不是4096
biomeBitsPerEntry = calculateBiomeBitsPerEntry(biomePalette.size(), biomeData);
}
// 解析并预解码光照数据(只保留 decoded 4096-byte 版本,不保留 raw nibble 2048-byte
byte[] decodedBlockLight = null;
byte[] decodedSkyLight = null;
byte[] rawBlockLight = sectionTag.getByteArray("BlockLight");
byte[] rawSkyLight = sectionTag.getByteArray("SkyLight");
if (rawBlockLight != null && rawBlockLight.length == 2048) {
decodedBlockLight = new byte[4096];
for (int i = 0; i < 2048; i++) {
int b = rawBlockLight[i] & 0xFF;
int idx = i << 1;
decodedBlockLight[idx] = (byte) (b & 0xF);
decodedBlockLight[idx + 1] = (byte) ((b >> 4) & 0xF);
}
}
if (rawSkyLight != null && rawSkyLight.length == 2048) {
decodedSkyLight = new byte[4096];
for (int i = 0; i < 2048; i++) {
int b = rawSkyLight[i] & 0xFF;
int idx = i << 1;
decodedSkyLight[idx] = (byte) (b & 0xF);
decodedSkyLight[idx + 1] = (byte) ((b >> 4) & 0xF);
}
}
// 预计算 blockData bit-reading 参数
int blockUVal = 0;
long blockMask = 0;
if (blockData != null && blockData.length > 0 && blockBitsPerEntry > 0) {
blockUVal = 64 / blockBitsPerEntry;
blockMask = (1L << blockBitsPerEntry) - 1L;
}
return new SectionData(
sectionY, blockPalette, blockNames, blockData, blockBitsPerEntry,
biomePalette, biomeData, biomeBitsPerEntry,
decodedBlockLight, decodedSkyLight,
blockUVal, blockMask
);
}
/**
* 解析单个方块状态的NBT
*
* <p>格式: {Name: "minecraft:grass_block", Properties: {snowy: "false"}}</p>
*
* @param stateTag 方块状态的NBT复合标签
* @return 解析后的BlockState对象
*/
private static BlockState parseBlockState(Tag.Compound stateTag) {
String name = stateTag.getString("Name");
if (!stateTag.contains("Properties", Tag.TAG_COMPOUND)) {
return new BlockState(name, BlockState.EMPTY_PROPERTIES);
}
Tag.Compound propsTag = stateTag.getCompound("Properties");
if (propsTag.children().isEmpty()) {
return new BlockState(name, BlockState.EMPTY_PROPERTIES);
}
Map<String, String> properties = new LinkedHashMap<>();
for (Map.Entry<String, Tag> entry : propsTag.children().entrySet()) {
Tag propTag = entry.getValue();
if (propTag instanceof Tag.StringTag str) {
properties.put(entry.getKey(), str.value());
}
}
if (properties.isEmpty()) {
return new BlockState(name, BlockState.EMPTY_PROPERTIES);
}
return new BlockState(name, properties);
}
/**
* 计算方块状态的bitsPerEntry(每个索引的位数)
*
* <p>根据Wiki规范:</p>
* <ul>
* <li>当paletteSize <= 1时,返回0(单方块section,无需data数组)</li>
* <li>当paletteSize <= 16时,返回4</li>
* <li>当paletteSize > 16时,返回 ceil(log2(paletteSize))</li>
* </ul>
*
* @param paletteSize 调色板大小
* @param data 位数组数据
* @return 每个条目的位数
*/
private static int calculateBitsPerEntry(int paletteSize, long[] data) {
if (paletteSize <= 1) {
return 0; // 单方块section,无需data数组
}
// Wiki规范:c ≤ 16 时 b = 4,否则 b = ceil(log2(c))
if (paletteSize <= 16) {
return 4;
}
// ceil(log2(c)) = 32 - numberOfLeadingZeros(c - 1)
return 32 - Integer.numberOfLeadingZeros(paletteSize - 1);
}
/**
* 计算生物群系的bitsPerEntry(每个索引的位数)
*
* <p>根据Wiki规范: 返回 ceil(log2(paletteSize))</p>
*
* @param paletteSize 调色板大小
* @param data 位数组数据
* @return 每个条目的位数
*/
private static int calculateBiomeBitsPerEntry(int paletteSize, long[] data) {
if (paletteSize <= 1) {
return 0; // 单biome section,无需data数组
}
// Wiki规范:b = ceil(log2(c))
// ceil(log2(c)) = 32 - numberOfLeadingZeros(c - 1)
return 32 - Integer.numberOfLeadingZeros(paletteSize - 1);
}
/**
* 从Section获取指定位置的方块状态(完整信息)
*
* @param section Section数据
* @param x 局部X坐标 (0-15)
* @param y 局部Y坐标 (0-15)
* @param z 局部Z坐标 (0-15)
* @return 方块状态对象,如果无效则返回空气状态
*/
public static BlockState getBlockStateAt(SectionData section, int x, int y, int z) {
if (section.blockPalette.isEmpty()) {
return new BlockState("minecraft:air", Map.of());
}
// 单方块palette
if (section.blockPalette.size() == 1) {
return section.blockPalette.get(0);
}
// 无数据
if (section.blockData == null || section.blockBitsPerEntry == 0) {
return new BlockState("minecraft:air", Map.of());
}
// 计算索引 (YZX顺序)
int blockIndex = (y << 8) | (z << 4) | x;
// 从位数组读取palette索引(使用预计算值避免重复除法/掩码)
int u = section.blockUVal();
long mask = section.blockMask();
int paletteIndex = readBitsFast(section.blockData, blockIndex, u, section.blockBitsPerEntry(), mask);
if (paletteIndex < 0 || paletteIndex >= section.blockPalette.size()) {
return new BlockState("minecraft:air", Map.of());
}
return section.blockPalette.get(paletteIndex);
}
/**
* 从Section获取指定位置的方块名称(仅名称)
*
* @param section Section数据
* @param x 局部X坐标 (0-15)
* @param y 局部Y坐标 (0-15)
* @param z 局部Z坐标 (0-15)
* @return 方块名称字符串
*/
public static String getBlockAt(SectionData section, int x, int y, int z) {
return getBlockStateAt(section, x, y, z).name();
}
/**
* 从Section获取指定位置的生物群系名称(默认不启用边界平滑)
*
* @param section Section数据
* @param x 局部X坐标 (0-15)
* @param y 局部Y坐标 (0-15)
* @param z 局部Z坐标 (0-15)
* @return 生物群系名称字符串
*/
public static String getBiomeAt(SectionData section, int x, int y, int z) {
return getBiomeAt(section, x, y, z, false);
}
/**
* 从Section获取指定位置的生物群系名称(支持边界平滑)
*
* biome 使用 4x4x4 voxel 格式,每个 voxel 覆盖 4x4 像素。
* 为了实现类似 Xaero 的 biome blending 效果,在 voxel 边缘位置
* 偏向相邻 voxel,使得 Xaero 客户端的十字形采样能获取不同的 biome。
*
* voxel 坐标系统 (以 voxel 为单位):
* - voxel (0,0) 覆盖像素 (0-3, 0-3)
* - voxel (1,0) 覆盖像素 (4-7, 0-3)
* - voxel (0,1) 覆盖像素 (0-3, 4-7)
*
* 平滑策略:
* - 每个像素的"名义 voxel"是其所在的 voxel
* - 但像素 (relX, relZ) 在 voxel 内的位置决定了它的实际采样位置
* - relX < 2 且 relZ < 2 (左上区域): 使用本 voxel 或左上方向 voxel
* - relX >= 2 且 relZ < 2 (右上区域): 倾向右侧 voxel
* - relX < 2 且 relZ >= 2 (左下区域): 倾向下方 voxel
* - relX >= 2 且 relZ >= 2 (右下区域): 倾向右下方向 voxel
*
* 参考 Xaero BiomeColorCalculator: 十字形采样5个位置计算平均颜色
*
* @param section Section数据
* @param x 局部X (0-15)
* @param y 局部Y (0-15)
* @param z 局部Z (0-15)
* @param smoothBoundary 是否启用边界平滑
*/
public static String getBiomeAt(SectionData section, int x, int y, int z, boolean smoothBoundary) {
// biomePalette 为空(section 无 biome 数据):返回 null,由调用方决定回退策略
if (section.biomePalette.isEmpty()) {
return null;
}
// 单生物群系palette
if (section.biomePalette.size() == 1) {
return section.biomePalette.get(0);
}
// palette 非空但无 data — 无效状态,返回 null 触发调用方回退
if (section.biomeData == null || section.biomeBitsPerEntry == 0) {
return null;
}
// 标准计算:voxelIndex = (y/4)*16 + (z/4)*4 + (x/4)
int voxelY = y >> 2;
int voxelZ = z >> 2;
int voxelX = x >> 2;
// 边界平滑:根据像素在 voxel 内的位置调整
if (smoothBoundary) {
// 计算像素在 voxel 内的相对位置 (0-3)
int relX = x & 3;
int relZ = z & 3;
// 核心思想:让 Xaero 的十字形采样能获取不同的 biome
// 十字形采样: (x-1,z), (x,z-1), (x,z), (x,z+1), (x+1,z)
// 需要让采样位置落在不同的 voxel 内
// 使用像素的实际位置作为"重心",然后找到包含该位置的 voxel
// 像素在 voxel 内的位置可以看作是相对于 voxel 左上角的偏移
// 偏移 0,1 → 倾向本 voxel
// 偏移 2,3 → 倾向相邻 voxel(因为接近 voxel 边界右侧/下侧)
// 如果 relX >= 2,像素接近 voxel 右边界,使用右侧 voxel
// 如果 relZ >= 2,像素接近 voxel 下边界,使用下方 voxel
if (relX >= 2 && voxelX < 3) {
voxelX++;
}
if (relZ >= 2 && voxelZ < 3) {
voxelZ++;
}
}
int voxelIndex = (voxelY << 4) | (voxelZ << 2) | voxelX;
int paletteIndex = readBitsFromArray(section.biomeData, voxelIndex, section.biomeBitsPerEntry);
if (paletteIndex < 0 || paletteIndex >= section.biomePalette.size()) {
return null;
}
return section.biomePalette.get(paletteIndex);
}
/**
* 从位数组读取指定索引的值(Wiki规范)
*
* <p>Wiki公式:</p>
* <ul>
* <li>u = floor(64/b),一个long能存储的元素数量</li>
* <li>getPalette(i) = (data[i/u] >>> ((i%u)*b)) & ((1L<<b)-1)</li>
* </ul>
*
* <p>元素不会跨long存储,每个元素完全在一个long内</p>
*
* @param data long数组
* @param index 元素序号(对于方块是YZX编码的索引,对于biome是voxel索引)
* @param bitsPerEntry 每个元素的位数b
* @return 调色板索引值
*/
public static int readBitsFromArray(long[] data, int index, int bitsPerEntry) {
if (data == null || data.length == 0 || bitsPerEntry <= 0) {
return 0;
}
int u = 64 / bitsPerEntry;
long mask = (1L << bitsPerEntry) - 1L;
return readBitsFast(data, index, u, bitsPerEntry, mask);
}
public static int readBitsFast(long[] data, int index, int u, int bitsPerEntry, long mask) {
int longIndex = index / u;
if (longIndex >= data.length) return 0;
int bitOffset = (index % u) * bitsPerEntry;
return (int) ((data[longIndex] >>> bitOffset) & mask);
}
/**
* 获取方块光照值(从nibble array
*
* @param section Section数据
* @param x 局部X坐标 (0-15)
* @param y 局部Y坐标 (0-15)
* @param z 局部Z坐标 (0-15)
* @return 方块光照值 (0-15)
*/
public static byte getBlockLight(SectionData section, int x, int y, int z) {
byte[] d = section.blockLight();
if (d != null && d.length == 4096) return d[(y << 8) | (z << 4) | x];
return 0;
}
/**
* 获取天空光照值
*
* @param section Section数据
* @param x 局部X坐标 (0-15)
* @param y 局部Y坐标 (0-15)
* @param z 局部Z坐标 (0-15)
* @return 天空光照值 (0-15)
*/
public static byte getSkyLight(SectionData section, int x, int y, int z) {
byte[] d = section.skyLight();
if (d != null && d.length == 4096) return d[(y << 8) | (z << 4) | x];
return 0;
}
/**
* 获取光照值(从nibble array- Wiki规范
*
* <p>亮度存储:每字节存储2个亮度值(4比特),2048字节存储4096个亮度</p>
* <p>写入顺序:YZX编码</p>
*
* <p>Wiki公式: getLight(x, y, z) = (data[yzx >> 1] >> (4 * (yzx & 1))) & 0xF</p>
* <p>其中 yzx = (y << 8) | (z << 4) | x</p>
*
* @param lightArray nibble数组(2048字节,存储4096个4位值)
* @param x 局部X坐标 (0-15)
* @param y 局部Y坐标 (0-15)
* @param z 局部Z坐标 (0-15)
* @return 光照值 (0-15)
*/
public static byte getLightValue(byte[] lightArray, int x, int y, int z) {
if (lightArray == null || lightArray.length != 2048) {
return 0;
}
// YZX编码序号
int yzx = (y << 8) | (z << 4) | x;
// Wiki公式:(data[yzx >> 1] >> (4 * (yzx & 1))) & 0xF
// yzx >> 1 找到字节索引(每字节存2个亮度)
// yzx & 1 判断是该字节的第几个亮度(0=低4位,1=高4位)
// 4 * (yzx & 1) 计算位偏移(0或4
return (byte) ((lightArray[yzx >> 1] >> (4 * (yzx & 1))) & 0xF);
}
/**
* 解析完整的光照数据(16x16x16 section
*
* <p>将nibble array转换为完整的4096字节光照数组</p>
*
* @param section Section数据
* @return 光照数据对象,索引格式 (y<<8)|(z<<4)|x
*/
public static LightData parseLightData(SectionData section) {
byte[] blockLight = new byte[4096];
byte[] skyLight = new byte[4096];
if (section.blockLight() != null && section.blockLight().length == 2048) {
for (int y = 0; y < 16; y++) {
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
int idx = (y << 8) | (z << 4) | x;
blockLight[idx] = getLightValue(section.blockLight(), x, y, z);
}
}
}
}
if (section.skyLight() != null && section.skyLight().length == 2048) {
for (int y = 0; y < 16; y++) {
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
int idx = (y << 8) | (z << 4) | x;
skyLight[idx] = getLightValue(section.skyLight(), x, y, z);
}
}
}
}
return new LightData(section.sectionY(), blockLight, skyLight);
}
/**
* 光照数据结构记录
*
* <p>存储解析后的完整光照数据(4096字节格式)</p>
*
* @param sectionY Section的世界Y坐标
* @param blockLight 方块光照数组(4096字节,每个位置0-15)
* @param skyLight 天空光照数组(4096字节,每个位置0-15)
*/
public record LightData(
int sectionY, // Section的世界Y坐标
byte[] blockLight, // 方块光照 (4096字节,每个位置0-15)
byte[] skyLight // 天空光照 (4096字节,每个位置0-15)
) {
/**
* 检查是否有光照数据
*
* @return 如果存在方块光照或天空光照数据则返回true
*/
public boolean hasLightData() {
return blockLight != null || skyLight != null;
}
/**
* 获取指定位置的方块光照值
*
* @param x 局部X坐标 (0-15)
* @param localY 局部Y坐标 (0-15,相对于section底部)
* @param z 局部Z坐标 (0-15)
* @return 方块光照值 (0-15)
*/
public byte getBlockLightAt(int x, int localY, int z) {
if (blockLight == null) return 0;
int idx = (localY << 8) | (z << 4) | x;
return idx < blockLight.length ? blockLight[idx] : 0;
}
/**
* 获取指定位置的天空光照值
*
* @param x 局部X坐标 (0-15)
* @param localY 局部Y坐标 (0-15,相对于section底部)
* @param z 局部Z坐标 (0-15)
* @return 天空光照值 (0-15)
*/
public byte getSkyLightAt(int x, int localY, int z) {
if (skyLight == null) return 0;
int idx = (localY << 8) | (z << 4) | x;
return idx < skyLight.length ? skyLight[idx] : 0;
}
/**
* 计算有效光照值(地表模式)
*
* <p>只使用 BlockLight,忽略 SkyLight</p>
*
* @param x 局部X坐标 (0-15)
* @param localY 局部Y坐标 (0-15)
* @param z 局部Z坐标 (0-15)
* @return 方块光照值
*/
public byte getEffectiveLightSurface(int x, int localY, int z) {
return getBlockLightAt(x, localY, z);
}
/**
* 计算有效光照值(洞穴模式)
*
* <p>Xaero 在日光条件下使用 skyLight=15,但水下使用 blockLight</p>
* <p>参考 Xaero WorldDataReader:537-561</p>
*
* @param x 局部X坐标 (0-15)
* @param localY 局部Y坐标 (0-15)
* @param z 局部Z坐标 (0-15)
* @param hasSkyAccess 是否有天空访问(位置高于高度图)
* @param hasOverlay 是否有覆盖层(水、玻璃等)
* @return 有效光照值 (0-15)
*/
public byte getEffectiveLightCave(int x, int localY, int z,
boolean hasSkyAccess, boolean hasOverlay) {
byte blockLight = getBlockLightAt(x, localY, z);
// 发光方块(火把等)BlockLight >= 15 时直接返回
if (blockLight >= 15) {
return blockLight;
}
// 有天空访问时 SkyLight = 15(直接日照)
if (hasSkyAccess && !hasOverlay) {
return 15;
}
// 无 overlay 时取 max(blockLight, skyLight)
if (!hasOverlay) {
byte skyLight = getSkyLightAt(x, localY, z);
return (byte) Math.max(blockLight, skyLight);
}
// 有 overlay(水下等)使用 BlockLight
return blockLight;
}
/**
* 计算有效光照值(通用方法)
*
* <p>根据光照模式和参数计算最终的有效光照</p>
*
* @param x 局部X坐标 (0-15)
* @param localY 局部Y坐标 (0-15)
* @param z 局部Z坐标 (0-15)
* @param lightMode 光照模式(SURFACE 或 CAVE
* @param hasSkyAccess 是否有天空访问
* @param hasOverlay 是否有覆盖层
* @param worldHasSkylight 维度是否有天空光照
* @return 有效光照值 (0-15)
*/
public byte getEffectiveLight(int x, int localY, int z,
LightMode lightMode,
boolean hasSkyAccess, boolean hasOverlay,
boolean worldHasSkylight) {
return lightMode.calculateEffectiveLight(
getBlockLightAt(x, localY, z),
getSkyLightAt(x, localY, z),
hasSkyAccess, hasOverlay, false, worldHasSkylight
);
}
}
}
@@ -0,0 +1,219 @@
package com.mapsyncer.mca;
/**
* 维度类型信息记录
*
* <p>存储 Minecraft 维度类型的核心属性,用于光照计算和世界高度范围确定。</p>
*
* <p>参考 Minecraft Wiki 维度类型:<a href="https://minecraft.wiki/w/Dimension_type">https://minecraft.wiki/w/Dimension_type</a></p>
*
* <p>关键属性:</p>
* <ul>
* <li>hasSkylight: 是否有天空光照(影响光照计算)</li>
* <li>hasCeiling: 是否有顶棚(地狱有顶棚)</li>
* <li>minY: 最小建筑高度(世界底部 Y 坐标)</li>
* <li>height: 维度总高度(minY + height = 最大建筑高度)</li>
* <li>logicalHeight: 逻辑高度(实际可操作高度,可能小于 height</li>
* </ul>
*
* <p>原版维度默认值:</p>
* <table border="1">
* <tr><th>维度</th><th>hasSkylight</th><th>hasCeiling</th><th>minY</th><th>height</th></tr>
* <tr><td>Overworld</td><td>true</td><td>false</td><td>-64</td><td>384</td></tr>
* <tr><td>Nether</td><td>false</td><td>true</td><td>0</td><td>256</td></tr>
* <tr><td>End</td><td>false</td><td>false</td><td>0</td><td>256</td></tr>
* </table>
*
* @param hasSkylight 是否有天空光照(影响光照计算)
* @param hasCeiling 是否有顶棚(地狱有顶棚,影响洞穴扫描)
* @param minY 最小建筑高度(世界底部 Y 坐标)
* @param height 维度总高度
* @param logicalHeight 逻辑高度(实际可操作高度)
*/
public record DimensionTypeInfo(
boolean hasSkylight, // 是否有天空光照
boolean hasCeiling, // 是否有顶棚
int minY, // 最小建筑高度(世界底部 Y
int height, // 维度总高度
int logicalHeight // 逻辑高度
) {
/**
* 获取最大建筑高度(minY + height
*
* @return 维度的最大建筑高度(世界顶部 Y 坐标)
*/
public int maxY() {
return minY + height;
}
/** 逻辑高度顶部的世界 Y 坐标(含) */
public int logicalTopY() {
return minY + logicalHeight - 1;
}
/** 是否存在逻辑顶以上的物理高度区(如地狱 Y128+) */
public boolean hasUpperZone() {
return hasCeiling && logicalHeight < height;
}
/**
* 创建默认的主世界维度类型信息
*
* <p>主世界特性:有天空光照、无顶棚、高度范围 -64 到 320</p>
*
* @return 主世界的维度类型信息实例
*/
public static DimensionTypeInfo overworld() {
return new DimensionTypeInfo(true, false, -64, 384, 384);
}
/**
* 创建默认的地狱维度类型信息
*
* <p>地狱特性:无天空光照、有顶棚、高度范围 0 到 256</p>
*
* @return 地狱的维度类型信息实例
*/
public static DimensionTypeInfo nether() {
return new DimensionTypeInfo(false, true, 0, 256, 128);
}
/**
* 创建默认的末地维度类型信息
*
* <p>末地特性:无天空光照、无顶棚、高度范围 0 到 256</p>
*
* @return 末地的维度类型信息实例
*/
public static DimensionTypeInfo theEnd() {
return new DimensionTypeInfo(false, false, 0, 256, 256);
}
/**
* 根据维度 ID 获取预设的维度类型信息
*
* @param dimensionId 维度 ID(如 "minecraft:overworld", "minecraft:the_nether", "the_end"
* @return 对应的维度类型信息,未知维度返回主世界默认值
*/
public static DimensionTypeInfo fromDimensionId(String dimensionId) {
if (dimensionId == null || dimensionId.isEmpty()) {
return overworld();
}
String normalized = dimensionId
.replace("minecraft:", "")
.toLowerCase();
switch (normalized) {
case "overworld":
return overworld();
case "the_nether":
return nether();
case "the_end":
return theEnd();
default:
// 未知维度使用主世界默认值
return overworld();
}
}
/**
* 获取默认 SkyLight 值
*
* <p>参考 Xaero WorldDataReader:353 行:</p>
* <ul>
* <li>有天空光照的维度:skyLightLevels[i] = 15</li>
* <li>无天空光照的维度:skyLightLevels[i] = 0</li>
* </ul>
*
* @return 默认 SkyLight 值(有天空光照返回15,否则返回0)
*/
public byte getDefaultSkyLight() {
return hasSkylight ? (byte) 15 : (byte) 0;
}
/**
* 判断是否为洞穴型维度(有顶棚)
*
* <p>洞穴型维度通常需要使用 CAVE 模式扫描</p>
* <p>地狱是典型的洞穴型维度</p>
*
* @return 如果维度有顶棚则返回true
*/
public boolean isCaveDimension() {
return hasCeiling;
}
/**
* 计算洞穴扫描的推荐起始高度
*
* <p>对于有顶棚的维度(地狱),推荐从 ceiling 下方开始</p>
* <p>对于普通维度,推荐从 sea level (63) 开始</p>
*
* @return 推荐的洞穴扫描起始高度(世界 Y 坐标)
*/
public int getRecommendedCaveStart() {
if (hasCeiling) {
// 地狱:ceiling 约在 Y=128,推荐从 63 开始向下扫描
return Math.max(minY + 32, (minY + height) / 2 - 32);
}
// 普通维度:从 sea level 开始
return Math.max(minY, 63);
}
/**
* 转换为配置字符串格式
*
* <p>格式:"hasSkylight|hasCeiling|minY|height|logicalHeight"</p>
* <p>用于配置文件存储和传输</p>
*
* @return 配置字符串表示形式
*/
public String toConfigString() {
return hasSkylight + "|" + hasCeiling + "|" + minY + "|" + height + "|" + logicalHeight;
}
/**
* 从配置字符串解析维度类型信息
*
* <p>格式:"hasSkylight|hasCeiling|minY|height|logicalHeight"</p>
* <p>如果字符串无效或格式不正确,返回主世界默认值</p>
*
* @param configStr 配置字符串
* @return 解析后的维度类型信息实例
*/
public static DimensionTypeInfo fromConfigString(String configStr) {
if (configStr == null || configStr.isEmpty()) {
return overworld();
}
String[] parts = configStr.split("\\|");
if (parts.length < 4) {
return overworld();
}
try {
boolean hasSkylight = Boolean.parseBoolean(parts[0]);
boolean hasCeiling = Boolean.parseBoolean(parts[1]);
int minY = Integer.parseInt(parts[2]);
int height = Integer.parseInt(parts[3]);
int logicalHeight = parts.length > 4 ? Integer.parseInt(parts[4]) : height;
return new DimensionTypeInfo(hasSkylight, hasCeiling, minY, height, logicalHeight);
} catch (NumberFormatException e) {
return overworld();
}
}
/**
* 获取维度类型信息的字符串表示形式
*
* @return 格式化的字符串,包含所有属性值
*/
@Override
public String toString() {
return String.format("DimensionTypeInfo[hasSkylight=%s, hasCeiling=%s, minY=%d, height=%d, maxY=%d]",
hasSkylight, hasCeiling, minY, height, maxY());
}
}
@@ -0,0 +1,154 @@
package com.mapsyncer.mca;
/**
* 光照模式枚举
*
* <p>定义两种光照计算模式,模拟 Xaero WorldMap 的光照处理逻辑:</p>
*
* <p>地表模式 (SURFACE):</p>
* <ul>
* <li>只使用 BlockLight(方块光照)</li>
* <li>SkyLight 完全忽略</li>
* <li>适用于普通地表地图渲染</li>
* <li>洞穴、地下室等区域显示较暗</li>
* </ul>
*
* <p>洞穴模式 (CAVE):</p>
* <ul>
* <li>同时使用 BlockLight 和 SkyLight</li>
* <li>取两者的最大值作为有效光照</li>
* <li>当方块光照 < 15 且有 SkyLight 时,考虑天空光照</li>
* <li>高于高度图的位置 SkyLight = 15(直接日照)</li>
* <li>模拟洞穴中透过水面看到阳光的效果</li>
* </ul>
*
* @see DimensionTypeInfo 用于确定维度的天空光照属性
* @see ChunkSectionParser.LightData 用于解析光照数据
*/
public enum LightMode {
/**
* 地表模式 - 只使用 BlockLight
*
* <p>光照计算规则:</p>
* <ul>
* <li>lightLevels = BlockLight 值</li>
* <li>SkyLight 完全忽略</li>
* <li>发光方块强制 light = 15</li>
* </ul>
*
* <p>适用场景:普通地表地图</p>
*/
SURFACE,
/**
* 洞穴模式 - 取 BlockLight 和 SkyLight 的最大值
*
* <p>光照计算规则(参考 Xaero WorldDataReader:537-561:</p>
* <ul>
* <li>默认 lightLevels = 0, skyLightLevels = 15(有天空的维度)</li>
* <li>当 BlockLight < 15 且有 SkyLight 时,记录 SkyLight</li>
* <li>最终光照 = max(BlockLight, SkyLight)</li>
* <li>高于高度图位置 SkyLight = 15</li>
* <li>无 overlay 且 SkyLight > BlockLight 时使用 SkyLight</li>
* </ul>
*
* <p>适用场景:洞穴地图、地下水查看</p>
*/
CAVE;
/**
* 计算有效光照值
*
* <p>参考 Xaero WorldDataReader.java 光照处理逻辑:</p>
* <ul>
* <li>第186行:worldHasSkylight = serverWorld.dimensionType().hasSkyLight()</li>
* <li>第353行:skyLightLevels[i] = worldHasSkylight ? 15 : 0</li>
* <li>第557-559行:cave && dataLight < 15 && worldHasSkylight 时更新 skyLightLevels</li>
* </ul>
*
* <p>末地维度特性:</p>
* <ul>
* <li>worldHasSkylight = false(末地没有天空光照)</li>
* <li>skyLightLevels 初始化为 0(而不是 15</li>
* <li>不会在光照计算中使用 skyLight = 15 作为默认值</li>
* </ul>
*
* @param blockLight 方块光照值 (0-15)
* @param skyLight 天空光照值 (0-15)
* @param hasSkyAccess 是否有天空访问(位置高于高度图)
* @param hasOverlay 是否有覆盖层(水、玻璃等透明方块)
* @param isGlowing 是否为发光方块
* @param worldHasSkylight 维度是否有天空光照(末地为 false)
* @return 有效光照值 (0-15)
*/
public byte calculateEffectiveLight(byte blockLight, byte skyLight,
boolean hasSkyAccess, boolean hasOverlay,
boolean isGlowing, boolean worldHasSkylight) {
// 发光方块强制光照15
if (isGlowing) {
return 15;
}
switch (this) {
case SURFACE:
// 地表模式:只使用 BlockLight
return blockLight;
case CAVE:
// 洞穴模式:取 max(BlockLight, SkyLight)
if (blockLight >= 15) {
return blockLight;
}
// 参考 Xaero: 只有在有天空光照的维度,有天空访问时才使用 SkyLight = 15
// 末地维度 worldHasSkylight = false,所以不会使用 15
byte effectiveSkyLight = (hasSkyAccess && worldHasSkylight) ? 15 : skyLight;
// 无 overlay 且 SkyLight 更亮时使用 SkyLight
if (!hasOverlay && effectiveSkyLight > blockLight) {
return effectiveSkyLight;
}
// 否则返回 BlockLight(水下等场景)
return blockLight;
default:
return blockLight;
}
}
/**
* 获取默认 SkyLight 值
*
* <p>根据光照模式和维度属性返回默认的天空光照值:</p>
* <ul>
* <li>地表模式: 返回 0(不使用 SkyLight</li>
* <li>洞穴模式: 如果维度有天空光照返回 15,否则返回 0</li>
* </ul>
*
* @param worldHasSkylight 世界是否有天空光照
* @return 默认 SkyLight 值(0 或 15
*/
public byte getDefaultSkyLight(boolean worldHasSkylight) {
switch (this) {
case SURFACE:
return (byte) 0; // 地表模式不使用 SkyLight
case CAVE:
return worldHasSkylight ? (byte) 15 : (byte) 0;
default:
return (byte) 0;
}
}
/**
* 判断是否需要 SkyLight 数据
*
* <p>只有洞穴模式需要 SkyLight 数据进行光照计算</p>
*
* @return 如果需要 SkyLight 数据则返回 true
*/
public boolean needsSkyLightData() {
return this == CAVE;
}
}
@@ -0,0 +1,70 @@
package com.mapsyncer.mca;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 快速检测 MCA 是否含有可转换的 chunk 数据(不解析 NBT)。
* 用于跳过 0 字节或仅含 location/timestamp 表头的空 region 文件。
*/
public final class McaContentProbe {
private static final Logger LOGGER = LoggerFactory.getLogger(McaContentProbe.class);
private static final int SECTOR_SIZE = 4096;
private static final int CHUNKS_PER_REGION = 32;
/** 空 MCA 仅含 location + timestamp 两扇区 */
private static final long HEADER_ONLY_SIZE = (long) SECTOR_SIZE * 2;
private McaContentProbe() {}
/**
* @return true 若文件存在且 location 表中至少有一个有效 chunk slot
*/
public static boolean hasAnyChunk(Path mcaPath) {
if (mcaPath == null || !Files.exists(mcaPath)) {
return false;
}
try {
long size = Files.size(mcaPath);
if (size == 0) {
return false;
}
if (size <= HEADER_ONLY_SIZE) {
return false;
}
} catch (IOException e) {
LOGGER.debug("Cannot stat MCA {}: {}", mcaPath, e.getMessage());
return false;
}
try (RandomAccessFile raf = new RandomAccessFile(mcaPath.toFile(), "r")) {
if (raf.length() < HEADER_ONLY_SIZE) {
return false;
}
for (int localX = 0; localX < CHUNKS_PER_REGION; localX++) {
for (int localZ = 0; localZ < CHUNKS_PER_REGION; localZ++) {
int index = (localX + localZ * CHUNKS_PER_REGION) * 4;
raf.seek(index);
int b0 = raf.readUnsignedByte();
int b1 = raf.readUnsignedByte();
int b2 = raf.readUnsignedByte();
int offsetSectors = (b0 << 16) | (b1 << 8) | b2;
int sectorCount = raf.readUnsignedByte();
if (offsetSectors > 0 && sectorCount > 0) {
return true;
}
}
}
return false;
} catch (IOException e) {
LOGGER.debug("MCA chunk probe failed for {}: {}", mcaPath, e.getMessage());
return false;
}
}
}
@@ -0,0 +1,337 @@
package com.mapsyncer.mca;
import com.mapsyncer.nbt.NbtReader;
import com.mapsyncer.nbt.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.util.zip.GZIPInputStream;
import java.util.zip.InflaterInputStream;
import net.jpountz.lz4.LZ4BlockInputStream;
/**
* MCA文件读取器 - 零依赖实现
* 解析Minecraft区域文件格式(.mca)
*
* <p>MCA文件结构:</p>
* <ul>
* <li>0-4KB: 位置表 (32x32 chunk位置,每个4字节)</li>
* <li>4-8KB: 时间戳表 (32x32 chunk时间戳,每个4字节)</li>
* <li>8KB+: chunk数据扇区 (每扇区4KB)</li>
* </ul>
*
* <p>支持的压缩类型:</p>
* <ul>
* <li>GZIP (类型1)</li>
* <li>ZLIB (类型2)</li>
* <li>无压缩 (类型3)</li>
* <li>LZ4 (类型4)</li>
* </ul>
*
* @see ChunkDataParser 用于解析chunk的NBT数据
* @see McaReader.ChunkLocation chunk位置信息记录
* @see McaReader.ChunkData chunk数据记录
*/
public class McaReader implements AutoCloseable {
private static final Logger LOGGER = LoggerFactory.getLogger(McaReader.class);
/**
* 扇区大小常量(4KB
*/
private static final int SECTOR_SIZE = 4096;
/**
* 每个区域的chunk数量(32x32
*/
private static final int CHUNKS_PER_REGION = 32;
// 压缩类型常量
/**
* GZIP压缩类型标识
*/
private static final int COMPRESS_GZIP = 1;
/**
* ZLIB压缩类型标识
*/
private static final int COMPRESS_ZLIB = 2;
/**
* 无压缩类型标识
*/
private static final int COMPRESS_NONE = 3;
/**
* LZ4 压缩类型标识(由 {@link #decompress} 通过 lz4-java 解压)
*/
private static final int COMPRESS_LZ4 = 4;
/**
* Chunk位置信息记录
*
* <p>包含chunk在MCA文件中的位置和元数据:</p>
* <ul>
* <li>offsetSectors: 数据起始位置的扇区偏移量</li>
* <li>sectorCount: 数据占用的扇区数量</li>
* <li>timestamp: chunk的最后修改时间戳</li>
* </ul>
*/
public record ChunkLocation(int offsetSectors, int sectorCount, int timestamp) {
/**
* 判断chunk是否存在
*
* @return 如果offsetSectors和sectorCount都大于0则返回true,否则返回false
*/
public boolean exists() {
return offsetSectors > 0 && sectorCount > 0;
}
/**
* 计算chunk数据的字节偏移量
*
* @return 数据在文件中的绝对字节偏移量(offsetSectors * SECTOR_SIZE
*/
public long dataOffset() {
return (long) offsetSectors * SECTOR_SIZE;
}
}
/**
* Chunk数据记录
*
* @param chunkX chunk在region内的局部X坐标 (0-31)
* @param chunkZ chunk在region内的局部Z坐标 (0-31)
* @param nbt chunk的NBT数据(Tag.Compound格式)
*/
public record ChunkData(int chunkX, int chunkZ, Tag.Compound nbt) {}
/**
* 随机访问文件对象,用于读取MCA文件
*/
private final RandomAccessFile raf;
private McaReader(RandomAccessFile raf) {
this.raf = raf;
}
/**
* 打开MCA文件并初始化读取器。
*
* @param path MCA文件的完整路径
* @return McaReader 实例
* @throws IOException 如果文件不存在、文件太小或无法读取
*/
public static McaReader open(String path) throws IOException {
RandomAccessFile raf = new RandomAccessFile(path, "r");
try {
if (raf.length() < SECTOR_SIZE * 2) {
throw new IOException("MCA文件太小: " + raf.length() + " bytes");
}
return new McaReader(raf);
} catch (IOException e) {
raf.close();
throw e;
}
}
/**
* 获取chunk在MCA文件中的位置信息
*
* <p>从位置表和时间戳表读取chunk的元数据</p>
*
* @param localX chunk在region内的局部X坐标 (0-31)
* @param localZ chunk在region内的局部Z坐标 (0-31)
* @return ChunkLocation对象,包含偏移量、扇区数和时间戳
* @throws IOException 如果读取文件失败
*/
public ChunkLocation getChunkLocation(int localX, int localZ) throws IOException {
int index = (localX + localZ * CHUNKS_PER_REGION) * 4;
raf.seek(index);
int b0 = raf.readUnsignedByte();
int b1 = raf.readUnsignedByte();
int b2 = raf.readUnsignedByte();
int offsetSectors = (b0 << 16) | (b1 << 8) | b2;
int sectorCount = raf.readUnsignedByte();
// 读取时间戳
raf.seek(SECTOR_SIZE + index);
int timestamp = raf.readInt();
return new ChunkLocation(offsetSectors, sectorCount, timestamp);
}
/**
* 读取单个chunk的NBT数据
*
* <p>处理流程:</p>
* <ol>
* <li>获取chunk位置信息</li>
* <li>读取压缩的数据长度和压缩类型</li>
* <li>解压缩数据</li>
* <li>解析NBT格式</li>
* </ol>
*
* @param localX chunk在region内的局部X坐标 (0-31)
* @param localZ chunk在region内的局部Z坐标 (0-31)
* @return chunk的NBT数据(Tag.Compound格式),如果chunk不存在或读取失败则返回null
* @throws IOException 如果读取或解压缩失败
*/
public Tag.Compound readChunkNbt(int localX, int localZ) throws IOException {
ChunkLocation loc = getChunkLocation(localX, localZ);
if (!loc.exists()) {
return null;
}
long dataOffset = loc.dataOffset();
if (dataOffset + 5 > raf.length()) {
return null;
}
raf.seek(dataOffset);
// 读取chunk数据长度(包含压缩类型字节)
int totalLength = raf.readInt();
if (totalLength <= 1) {
return null;
}
// 读取压缩类型
int compressionType = raf.readUnsignedByte();
// 读取压缩数据
int dataLength = totalLength - 1;
byte[] compressedData = new byte[dataLength];
int read = 0;
while (read < dataLength) {
int r = raf.read(compressedData, read, dataLength - read);
if (r == -1) break;
read += r;
}
if (read != dataLength) {
return null;
}
// 解压缩
byte[] nbtData = decompress(compressedData, compressionType);
if (nbtData == null) {
return null;
}
// 解析NBT
try (NbtReader reader = new NbtReader(new ByteArrayInputStream(nbtData))) {
return reader.readDocument();
}
}
/**
* 逐 chunk 读取并回调,不预先加载全区 NBT 到内存。
*
* @param consumer 每个成功读取的 chunk 回调
* @throws IOException 如果打开或读取文件失败
*/
public void forEachChunk(java.util.function.Consumer<ChunkData> consumer) throws IOException {
for (int localX = 0; localX < CHUNKS_PER_REGION; localX++) {
for (int localZ = 0; localZ < CHUNKS_PER_REGION; localZ++) {
try {
Tag.Compound nbt = readChunkNbt(localX, localZ);
if (nbt != null) {
consumer.accept(new ChunkData(localX, localZ, nbt));
}
} catch (IOException e) {
LOGGER.warn("读取chunk ({}, {}) 失败: {}", localX, localZ, e.getMessage());
}
}
}
}
/**
* 读取区域文件中所有存在的chunk
*
* <p>遍历32x32的所有chunk位置,读取每个存在的chunk数据</p>
* <p>单个chunk读取失败不会中断整体读取过程,会记录警告日志</p>
*
* @return 包含所有成功读取的ChunkData对象的列表
* @throws IOException 如果打开或读取文件失败
* @deprecated 大 region 会占用大量内存;请使用 {@link #forEachChunk} 或 {@link McaRegionLoader} 的流式路径
*/
@Deprecated
public Iterable<ChunkData> readAllChunks() throws IOException {
java.util.List<ChunkData> chunks = new java.util.ArrayList<>();
forEachChunk(chunks::add);
return chunks;
}
/**
* 解压缩chunk数据
*
* <p>根据压缩类型选择相应的解压缩方法:</p>
* <ul>
* <li>GZIP (1): 使用GZIPInputStream解压</li>
* <li>ZLIB (2): 使用InflaterInputStream解压</li>
* <li>无压缩 (3): 直接返回原始数据</li>
* <li>LZ4 (4): LZ4 块压缩 (Minecraft 1.21.2+ 默认格式)</li>
* </ul>
*
* @param data 压缩的数据字节数组
* @param compressionType 压缩类型标识 (1-4)
* @return 解压缩后的NBT数据字节数组
* @throws IOException 如果解压缩失败或压缩类型不支持
*/
private byte[] decompress(byte[] data, int compressionType) throws IOException {
ByteArrayInputStream bais = new ByteArrayInputStream(data);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
switch (compressionType) {
case COMPRESS_GZIP:
try (GZIPInputStream gis = new GZIPInputStream(bais)) {
byte[] buf = new byte[8192];
int len;
while ((len = gis.read(buf)) > 0) {
baos.write(buf, 0, len);
}
}
return baos.toByteArray();
case COMPRESS_ZLIB:
try (InflaterInputStream iis = new InflaterInputStream(bais)) {
byte[] buf = new byte[8192];
int len;
while ((len = iis.read(buf)) > 0) {
baos.write(buf, 0, len);
}
}
return baos.toByteArray();
case COMPRESS_NONE:
return data;
case COMPRESS_LZ4:
try (LZ4BlockInputStream lis = new LZ4BlockInputStream(bais)) {
byte[] buf = new byte[8192];
int len;
while ((len = lis.read(buf)) > 0) {
baos.write(buf, 0, len);
}
}
return baos.toByteArray();
default:
throw new IOException("未知压缩类型: " + compressionType);
}
}
/**
* 关闭MCA文件读取器
*
* <p>释放文件资源,实现AutoCloseable接口以支持try-with-resources语法</p>
*
* @throws IOException 如果关闭文件失败
*/
@Override
public void close() throws IOException {
raf.close();
}
}
@@ -0,0 +1,116 @@
package com.mapsyncer.mca;
import com.mapsyncer.mca.convert.RegionConversionPipeline;
import com.mapsyncer.mca.convert.model.ConvertConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.mapsyncer.mca.convert.scan.RegionScanPass;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* 独立的区域转换器 - 不依赖 Minecraft 库
*
* <p>使用自研 MCA 解析器读取 .mca 文件,转换为 Xaero WorldMap 格式。</p>
*
* <p>实现已迁移至 {@link com.mapsyncer.mca.convert} 包;本类保留 public API 入口。</p>
*
* @see com.mapsyncer.mca.convert.RegionConversionPipeline
* @see McaReader 用于读取 MCA 文件
* @see ChunkDataParser 用于解析 Chunk 数据
* @see ChunkSectionParser 用于解析 Section 数据
* @see LightMode 光照模式枚举
* @see DimensionTypeInfo 维度类型信息
*/
public class RegionConverterStandalone {
private static final Logger LOGGER = LoggerFactory.getLogger(RegionConverterStandalone.class);
public static final int REGION_SIZE_BLOCKS = ConvertConstants.REGION_SIZE_BLOCKS;
public static final int CHUNKS_PER_REGION = ConvertConstants.CHUNKS_PER_REGION;
public static final int BLOCKS_PER_TILE_CHUNK = ConvertConstants.BLOCKS_PER_TILE_CHUNK;
public static final int BLOCKS_PER_TILE = ConvertConstants.BLOCKS_PER_TILE;
public static final int TILES_PER_TILE_CHUNK = ConvertConstants.TILES_PER_TILE_CHUNK;
public static final int TILE_CHUNKS_PER_REGION = ConvertConstants.TILE_CHUNKS_PER_REGION;
public static final int MAJOR_VERSION = ConvertConstants.MAJOR_VERSION;
public static final int MINOR_VERSION = ConvertConstants.MINOR_VERSION;
public record ConvertedRegion(int regionX, int regionZ, byte[] xaeroData) {}
/** 多 pass 转换结果,含 caveLayer(地表层为 Integer.MAX_VALUE */
public record LayerConvertedRegion(int regionX, int regionZ, int caveLayer, byte[] xaeroData) {}
public record CaveModeParams(int caveStart, int caveDepth) {
public static final CaveModeParams NONE = new CaveModeParams(Integer.MAX_VALUE, 0);
public static CaveModeParams createDefault(int worldTopY, int defaultDepth) {
return new CaveModeParams(worldTopY, defaultDepth);
}
}
public static ConvertedRegion convertRegion(Path mcaPath, int regionX, int regionZ,
int minBuildHeight, int worldTopY,
BlockPropertyLookup blockLookup) {
return convertRegion(mcaPath, regionX, regionZ, minBuildHeight, worldTopY,
LightMode.SURFACE, CaveModeParams.NONE, true, blockLookup);
}
public static ConvertedRegion convertRegion(Path mcaPath, int regionX, int regionZ,
int minBuildHeight, int worldTopY,
LightMode lightMode,
CaveModeParams caveParams,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup) {
if (!Files.exists(mcaPath)) {
return null;
}
try {
return RegionConversionPipeline.convert(
mcaPath, regionX, regionZ, minBuildHeight, worldTopY,
lightMode, caveParams, worldHasSkylight, blockLookup);
} catch (IOException e) {
LOGGER.warn("Failed to convert region ({}, {})", regionX, regionZ, e);
return null;
}
}
public static ConvertedRegion convertRegion(Path mcaPath, int regionX, int regionZ,
DimensionTypeInfo dimTypeInfo,
LightMode lightMode,
CaveModeParams caveParams,
BlockPropertyLookup blockLookup) {
if (!Files.exists(mcaPath)) {
return null;
}
try {
return RegionConversionPipeline.convert(
mcaPath, regionX, regionZ, dimTypeInfo, lightMode, caveParams, blockLookup);
} catch (IOException e) {
LOGGER.warn("Failed to convert region ({}, {})", regionX, regionZ, e);
return null;
}
}
public static List<LayerConvertedRegion> convertRegionMulti(
Path mcaPath, int regionX, int regionZ,
DimensionTypeInfo dimTypeInfo,
List<RegionScanPass> passes,
BlockPropertyLookup blockLookup) {
if (!Files.exists(mcaPath)) {
return List.of();
}
try {
return RegionConversionPipeline.convertMulti(
mcaPath, regionX, regionZ, dimTypeInfo, passes, blockLookup);
} catch (IOException e) {
LOGGER.warn("Failed to convert region ({}, {}) multi-pass", regionX, regionZ, e);
return List.of();
}
}
}
@@ -0,0 +1,98 @@
package com.mapsyncer.mca.convert;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkMask;
import com.mapsyncer.mca.DimensionTypeInfo;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.RegionConverterStandalone;
import com.mapsyncer.mca.convert.io.McaRegionLoader;
import com.mapsyncer.mca.convert.io.McaRegionLoader.PassMapData;
import com.mapsyncer.mca.convert.io.XaeroBinaryWriter;
import com.mapsyncer.mca.convert.model.MapRegionData;
import com.mapsyncer.mca.convert.scan.RegionScanPass;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public final class RegionConversionPipeline {
private RegionConversionPipeline() {}
public static RegionConverterStandalone.ConvertedRegion convert(
Path mcaPath, int regionX, int regionZ,
int minBuildHeight, int worldTopY,
LightMode lightMode,
RegionConverterStandalone.CaveModeParams caveParams,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup) throws IOException {
MapRegionData regionData = McaRegionLoader.load(
mcaPath, minBuildHeight, worldTopY, lightMode, caveParams, worldHasSkylight, blockLookup);
if (!regionData.hasAnyMapData()) {
return new RegionConverterStandalone.ConvertedRegion(regionX, regionZ, new byte[0]);
}
byte[] xaeroData = XaeroBinaryWriter.serialize(regionData, minBuildHeight, blockLookup);
return new RegionConverterStandalone.ConvertedRegion(regionX, regionZ, xaeroData);
}
public static RegionConverterStandalone.ConvertedRegion convert(
Path mcaPath, int regionX, int regionZ,
DimensionTypeInfo dimTypeInfo,
LightMode lightMode,
RegionConverterStandalone.CaveModeParams caveParams,
BlockPropertyLookup blockLookup) throws IOException {
return convert(mcaPath, regionX, regionZ,
dimTypeInfo.minY(), dimTypeInfo.maxY(),
lightMode, caveParams, dimTypeInfo.hasSkylight(), blockLookup);
}
/**
* 单次 MCA 解析,输出多个层/地表 pass 的转换结果。
*/
public static List<RegionConverterStandalone.LayerConvertedRegion> convertMulti(
Path mcaPath, int regionX, int regionZ,
DimensionTypeInfo dimTypeInfo,
List<RegionScanPass> passes,
BlockPropertyLookup blockLookup) throws IOException {
return convertMulti(mcaPath, regionX, regionZ, dimTypeInfo, passes, blockLookup, ChunkMask.ALL);
}
/**
* 单次 MCA 解析,输出多个层/地表 pass 的转换结果;只渲染 {@code mask} 允许的区块。
*/
public static List<RegionConverterStandalone.LayerConvertedRegion> convertMulti(
Path mcaPath, int regionX, int regionZ,
DimensionTypeInfo dimTypeInfo,
List<RegionScanPass> passes,
BlockPropertyLookup blockLookup,
ChunkMask mask) throws IOException {
if (!Files.exists(mcaPath) || passes.isEmpty()) {
return List.of();
}
List<PassMapData> loaded = McaRegionLoader.loadMulti(
mcaPath, dimTypeInfo.minY(), dimTypeInfo.maxY(),
dimTypeInfo.hasSkylight(), blockLookup, passes, mask);
List<RegionConverterStandalone.LayerConvertedRegion> results = new ArrayList<>();
for (PassMapData passData : loaded) {
MapRegionData regionData = passData.data();
if (!regionData.hasAnyMapData()) {
results.add(new RegionConverterStandalone.LayerConvertedRegion(
regionX, regionZ, passData.pass().caveLayer(), new byte[0]));
continue;
}
byte[] xaeroData = XaeroBinaryWriter.serialize(regionData, dimTypeInfo.minY(), blockLookup);
results.add(new RegionConverterStandalone.LayerConvertedRegion(
regionX, regionZ, passData.pass().caveLayer(), xaeroData));
}
return results;
}
}
@@ -0,0 +1,62 @@
package com.mapsyncer.mca.convert.biome;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.convert.model.MapRegionData;
import static com.mapsyncer.mca.convert.model.ConvertConstants.CHUNKS_PER_REGION;
import static com.mapsyncer.mca.convert.model.ConvertConstants.REGION_SIZE_BLOCKS;
/**
* 扫描完成后填充 biome,对齐 Xaero fillBiomes(按 topHeight/height 采样)。
*
* <p>地表层:有扫描结果时用 heightMap;否则用高度图地表 Y。</p>
* <p>洞穴层:有扫描结果时用洞穴壁 Y;否则用 caveStart,且不回退到地表群系。</p>
*/
public final class BiomeFillPass {
private BiomeFillPass() {}
public static void fill(MapRegionData data) {
for (int rx = 0; rx < REGION_SIZE_BLOCKS; rx++) {
for (int rz = 0; rz < REGION_SIZE_BLOCKS; rz++) {
int chunkX = rx >> 4;
int chunkZ = rz >> 4;
if (chunkX >= CHUNKS_PER_REGION || chunkZ >= CHUNKS_PER_REGION) {
continue;
}
ChunkDataParser.ChunkInfo chunk = data.chunkGrid[chunkX][chunkZ];
if (chunk == null) {
continue;
}
int lx = rx & 0xF;
int lz = rz & 0xF;
int[][] heightmap = chunk.heightmap();
boolean caveMode = data.lightMode == LightMode.CAVE
&& data.caveParams.caveStart() != Integer.MAX_VALUE;
int sampleY;
if (data.hasData[rx][rz]) {
sampleY = data.heightMap[rx][rz];
} else if (caveMode) {
sampleY = data.caveParams.caveStart();
} else if (heightmap != null) {
sampleY = heightmap[lx][lz];
data.heightMap[rx][rz] = sampleY;
} else {
continue;
}
String biome = caveMode
? BiomeQuartResolver.resolveAtY(chunk, lx, sampleY, lz)
: BiomeQuartResolver.resolve(chunk, lx, sampleY, lz);
if (BiomeQuartResolver.isValidBiome(biome)) {
data.biomeNames[rx][rz] = biome;
}
}
}
}
}
@@ -0,0 +1,77 @@
package com.mapsyncer.mca.convert.biome;
import com.mapsyncer.mca.ChunkSectionParser;
import java.util.List;
/**
* 预计算的 chunk 内 quart4×4×4biome 体素表,将 fill 阶段查表降为 O(1)。
*/
public final class BiomeQuartGrid {
private static final int VOXELS_PER_SECTION = 64;
private final int minSectionY;
private final String[][] sectionVoxels;
private BiomeQuartGrid(int minSectionY, String[][] sectionVoxels) {
this.minSectionY = minSectionY;
this.sectionVoxels = sectionVoxels;
}
public static BiomeQuartGrid build(List<ChunkSectionParser.SectionData> sections,
int minSectionY,
ChunkSectionParser.SectionData[] sectionLookup) {
if (sectionLookup == null || sectionLookup.length == 0) {
return new BiomeQuartGrid(minSectionY, new String[0][]);
}
String[][] grids = new String[sectionLookup.length][];
for (ChunkSectionParser.SectionData section : sections) {
if (section == null || section.biomePalette().isEmpty()) {
continue;
}
int idx = section.sectionY() - minSectionY;
if (idx < 0 || idx >= grids.length) {
continue;
}
String[] voxels = new String[VOXELS_PER_SECTION];
if (section.biomePalette().size() == 1) {
String only = section.biomePalette().get(0);
java.util.Arrays.fill(voxels, only);
} else {
for (int voxelY = 0; voxelY < 4; voxelY++) {
for (int voxelZ = 0; voxelZ < 4; voxelZ++) {
for (int voxelX = 0; voxelX < 4; voxelX++) {
int blockX = voxelX << 2;
int blockY = voxelY << 2;
int blockZ = voxelZ << 2;
int voxelIndex = (voxelY << 4) | (voxelZ << 2) | voxelX;
voxels[voxelIndex] = ChunkSectionParser.getBiomeAt(
section, blockX, blockY, blockZ, false);
}
}
}
}
grids[idx] = voxels;
}
return new BiomeQuartGrid(minSectionY, grids);
}
/**
* O(1) quart 查表;无数据时返回 null(由 {@link BiomeQuartResolver} 走原有回退链)。
*/
public String lookup(int lx, int absoluteY, int lz) {
int sectionIdx = (absoluteY >> 4) - minSectionY;
if (sectionIdx < 0 || sectionIdx >= sectionVoxels.length) {
return null;
}
String[] voxels = sectionVoxels[sectionIdx];
if (voxels == null) {
return null;
}
int localY = absoluteY & 0xF;
int voxelIndex = ((localY >> 2) << 4) | ((lz >> 2) << 2) | (lx >> 2);
return voxels[voxelIndex];
}
}
@@ -0,0 +1,157 @@
package com.mapsyncer.mca.convert.biome;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkSectionParser;
import static com.mapsyncer.mca.convert.model.ConvertConstants.DEFAULT_BIOME;
/**
* 按 quart4×4×4)精度解析 chunk 内 biome,对齐 Xaero fillBiomes 的采样方式。
* 不包含邻域 region chunk 加载。
*/
public final class BiomeQuartResolver {
private BiomeQuartResolver() {}
public static String resolve(ChunkDataParser.ChunkInfo chunk, int lx, int absoluteY, int lz) {
return resolve(chunk, lx, absoluteY, lz, false);
}
/**
* 仅在指定 Y 及其 section 内解析 biome,不回退到高度图地表 Y。
* 洞穴层采样时使用,避免 Y=63 的像素被替换成 Y=127 的地表群系。
*/
public static String resolveAtY(ChunkDataParser.ChunkInfo chunk, int lx, int absoluteY, int lz) {
return resolveAtY(chunk, lx, absoluteY, lz, false);
}
public static String resolveAtY(ChunkDataParser.ChunkInfo chunk, int lx, int absoluteY, int lz,
boolean smoothBoundary) {
String biome = resolveBiomeAtAbsoluteY(chunk, lx, absoluteY, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.biomePalette().isEmpty()) {
continue;
}
int fallbackLy = absoluteY - s.sectionY() * 16;
if (fallbackLy < 0 || fallbackLy > 15) {
continue;
}
biome = ChunkSectionParser.getBiomeAt(s, lx, fallbackLy, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
}
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.biomePalette().isEmpty()) {
continue;
}
for (int tryLy = 0; tryLy <= 15; tryLy++) {
String candidate = ChunkSectionParser.getBiomeAt(s, lx, tryLy, lz, smoothBoundary);
if (isValidBiome(candidate)) {
return candidate;
}
}
}
return DEFAULT_BIOME;
}
public static String resolve(ChunkDataParser.ChunkInfo chunk, int lx, int absoluteY, int lz,
boolean smoothBoundary) {
String biome = resolveBiomeAtAbsoluteY(chunk, lx, absoluteY, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
int[][] heightmap = chunk.heightmap();
if (heightmap != null) {
int surfaceY = heightmap[lx][lz];
biome = resolveBiomeAtAbsoluteY(chunk, lx, surfaceY, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
}
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.biomePalette().isEmpty()) {
continue;
}
int fallbackLy = absoluteY - s.sectionY() * 16;
if (fallbackLy < 0 || fallbackLy > 15) {
continue;
}
biome = ChunkSectionParser.getBiomeAt(s, lx, fallbackLy, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
}
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.biomePalette().isEmpty()) {
continue;
}
for (int tryLy = 0; tryLy <= 15; tryLy++) {
String candidate = ChunkSectionParser.getBiomeAt(s, lx, tryLy, lz, smoothBoundary);
if (isValidBiome(candidate)) {
return candidate;
}
}
}
return DEFAULT_BIOME;
}
private static String resolveBiomeAtAbsoluteY(ChunkDataParser.ChunkInfo chunk,
int lx, int absoluteY, int lz,
boolean smoothBoundary) {
if (!smoothBoundary && chunk.biomeGrid() != null) {
String gridBiome = chunk.biomeGrid().lookup(lx, absoluteY, lz);
if (isValidBiome(gridBiome)) {
return gridBiome;
}
}
String biome = ChunkDataParser.getBiomeAt(chunk, lx, absoluteY, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
int targetSectionY = absoluteY >> 4;
int localY = absoluteY & 0xF;
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.sectionY() != targetSectionY || s.biomePalette().isEmpty()) {
continue;
}
biome = ChunkSectionParser.getBiomeAt(s, lx, localY, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
}
for (ChunkSectionParser.SectionData s : chunk.sections()) {
if (s.biomePalette().isEmpty()) {
continue;
}
int fallbackLy = absoluteY - s.sectionY() * 16;
if (fallbackLy < 0 || fallbackLy > 15) {
continue;
}
biome = ChunkSectionParser.getBiomeAt(s, lx, fallbackLy, lz, smoothBoundary);
if (isValidBiome(biome)) {
return biome;
}
}
return null;
}
static boolean isValidBiome(String biome) {
return biome != null && !biome.equals(DEFAULT_BIOME);
}
}
@@ -0,0 +1,144 @@
package com.mapsyncer.mca.convert.io;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkMask;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.McaReader;
import com.mapsyncer.mca.RegionConverterStandalone;
import com.mapsyncer.mca.convert.biome.BiomeFillPass;
import com.mapsyncer.mca.convert.model.ConvertConstants;
import com.mapsyncer.mca.convert.model.MapRegionData;
import com.mapsyncer.mca.convert.scan.ChunkColumnScanner;
import com.mapsyncer.mca.convert.scan.RegionScanPass;
import com.mapsyncer.nbt.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public final class McaRegionLoader {
private static final Logger LOGGER = LoggerFactory.getLogger(McaRegionLoader.class);
private McaRegionLoader() {}
public record PassMapData(RegionScanPass pass, MapRegionData data) {}
public static MapRegionData load(Path mcaPath, int minBuildHeight, int worldTopY,
LightMode lightMode,
RegionConverterStandalone.CaveModeParams caveParams,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup) throws IOException {
MapRegionData data = new MapRegionData(minBuildHeight, lightMode, caveParams);
try (McaReader reader = McaReader.open(mcaPath.toString())) {
int worldHeightRange = worldTopY - minBuildHeight;
ChunkDataParser.ChunkInfo[][] chunks = readAllChunks(reader, worldHeightRange);
for (int localX = 0; localX < ConvertConstants.CHUNKS_PER_REGION; localX++) {
for (int localZ = 0; localZ < ConvertConstants.CHUNKS_PER_REGION; localZ++) {
ChunkDataParser.ChunkInfo chunkInfo = chunks[localX][localZ];
if (chunkInfo == null) {
continue;
}
ChunkColumnScanner.scan(data, chunkInfo, minBuildHeight, worldTopY,
lightMode, caveParams, worldHasSkylight, blockLookup);
}
}
}
BiomeFillPass.fill(data);
return data;
}
/**
* 单次 MCA 解析,按多个扫描 pass 输出多份 MapRegionData。
*/
public static List<PassMapData> loadMulti(Path mcaPath, int minBuildHeight, int worldTopY,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup,
List<RegionScanPass> passes) throws IOException {
return loadMulti(mcaPath, minBuildHeight, worldTopY, worldHasSkylight, blockLookup, passes, ChunkMask.ALL);
}
/**
* 单次 MCA 解析,按多个扫描 pass 输出多份 MapRegionData;只处理 {@code mask} 允许的区块。
*/
public static List<PassMapData> loadMulti(Path mcaPath, int minBuildHeight, int worldTopY,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup,
List<RegionScanPass> passes,
ChunkMask mask) throws IOException {
if (passes.isEmpty()) {
return List.of();
}
List<PassMapData> results = new ArrayList<>(passes.size());
for (RegionScanPass pass : passes) {
results.add(new PassMapData(pass, new MapRegionData(minBuildHeight, pass.lightMode(), pass.caveParams())));
}
try (McaReader reader = McaReader.open(mcaPath.toString())) {
int worldHeightRange = worldTopY - minBuildHeight;
ChunkDataParser.ChunkInfo[][] chunks = readAllChunks(reader, worldHeightRange, mask);
for (int localX = 0; localX < ConvertConstants.CHUNKS_PER_REGION; localX++) {
for (int localZ = 0; localZ < ConvertConstants.CHUNKS_PER_REGION; localZ++) {
ChunkDataParser.ChunkInfo chunkInfo = chunks[localX][localZ];
if (chunkInfo == null) {
continue;
}
for (PassMapData passData : results) {
RegionScanPass pass = passData.pass();
ChunkColumnScanner.scan(
passData.data(), chunkInfo, minBuildHeight, worldTopY,
pass.lightMode(), pass.caveParams(), worldHasSkylight, blockLookup,
pass.verticalBounds());
}
}
}
}
for (PassMapData passData : results) {
BiomeFillPass.fill(passData.data());
}
return results;
}
private static ChunkDataParser.ChunkInfo[][] readAllChunks(McaReader reader, int worldHeightRange)
throws IOException {
return readAllChunks(reader, worldHeightRange, ChunkMask.ALL);
}
private static ChunkDataParser.ChunkInfo[][] readAllChunks(McaReader reader, int worldHeightRange,
ChunkMask mask)
throws IOException {
ChunkDataParser.ChunkInfo[][] grid =
new ChunkDataParser.ChunkInfo[ConvertConstants.CHUNKS_PER_REGION][ConvertConstants.CHUNKS_PER_REGION];
for (int localX = 0; localX < ConvertConstants.CHUNKS_PER_REGION; localX++) {
for (int localZ = 0; localZ < ConvertConstants.CHUNKS_PER_REGION; localZ++) {
if (!mask.includes(localX, localZ)) {
continue;
}
Tag.Compound nbt;
try {
nbt = reader.readChunkNbt(localX, localZ);
} catch (IOException e) {
LOGGER.warn("Failed to read chunk ({}, {}) from region file, skipping: {}",
localX, localZ, e.getMessage());
continue;
}
if (nbt == null) {
continue;
}
grid[localX][localZ] = ChunkDataParser.parseChunk(localX, localZ, nbt, worldHeightRange);
}
}
return grid;
}
}
@@ -0,0 +1,267 @@
package com.mapsyncer.mca.convert.io;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.convert.io.XaeroBlockStateNbtWriter.PaletteKey;
import com.mapsyncer.mca.convert.model.MapRegionData;
import com.mapsyncer.mca.convert.model.OverlayEntry;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.mapsyncer.mca.convert.model.ConvertConstants.BLOCKS_PER_TILE;
import static com.mapsyncer.mca.convert.model.ConvertConstants.DEFAULT_BIOME;
import static com.mapsyncer.mca.convert.model.ConvertConstants.DEFAULT_BLOCK;
import static com.mapsyncer.mca.convert.model.ConvertConstants.MAJOR_VERSION;
import static com.mapsyncer.mca.convert.model.ConvertConstants.MINOR_VERSION;
import static com.mapsyncer.mca.convert.model.ConvertConstants.REGION_SIZE_BLOCKS;
import static com.mapsyncer.mca.convert.model.ConvertConstants.TILE_CHUNKS_PER_REGION;
import static com.mapsyncer.mca.convert.model.ConvertConstants.TILES_PER_TILE_CHUNK;
public final class XaeroBinaryWriter {
private XaeroBinaryWriter() {}
public static byte[] serialize(MapRegionData data, int minBuildHeight,
BlockPropertyLookup blockLookup) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (DataOutputStream dos = new DataOutputStream(baos)) {
dos.writeByte(0xFF);
dos.writeInt((MAJOR_VERSION << 16) | MINOR_VERSION);
Map<PaletteKey, Integer> blockPalette = new LinkedHashMap<>();
Map<String, Integer> biomePalette = new LinkedHashMap<>();
for (int tileChunkO = 0; tileChunkO < TILE_CHUNKS_PER_REGION; tileChunkO++) {
for (int tileChunkP = 0; tileChunkP < TILE_CHUNKS_PER_REGION; tileChunkP++) {
dos.writeByte((tileChunkO << 4) | tileChunkP);
for (int tileI = 0; tileI < TILES_PER_TILE_CHUNK; tileI++) {
for (int tileJ = 0; tileJ < TILES_PER_TILE_CHUNK; tileJ++) {
int chunkX = tileChunkO * 4 + tileI;
int chunkZ = tileChunkP * 4 + tileJ;
int baseX = chunkX * 16;
int baseZ = chunkZ * 16;
if (!data.chunkExists[chunkX][chunkZ]) {
dos.writeInt(-1);
continue;
}
for (int bx = 0; bx < BLOCKS_PER_TILE; bx++) {
for (int bz = 0; bz < BLOCKS_PER_TILE; bz++) {
int rx = baseX + bx;
int rz = baseZ + bz;
if (!data.hasData[rx][rz]) {
if (data.lightMode == LightMode.CAVE) {
writeCaveEmptyPixel(dos, data, rx, rz, minBuildHeight,
blockPalette, biomePalette);
} else {
writeEmptyPixel(dos, data, rx, rz, minBuildHeight,
blockPalette, biomePalette);
}
continue;
}
writePixel(dos, data, rx, rz, blockPalette, biomePalette, blockLookup);
}
}
dos.writeByte(1);
dos.writeInt(data.caveParams.caveStart());
dos.writeByte(data.caveParams.caveDepth() & 0xFF);
}
}
}
}
}
return baos.toByteArray();
}
private static void writeCaveEmptyPixel(DataOutputStream dos, MapRegionData data, int rx, int rz,
int minBuildHeight,
Map<PaletteKey, Integer> blockPalette,
Map<String, Integer> biomePalette) throws IOException {
BlockState air = XaeroBlockStateNbtWriter.AIR;
PaletteKey paletteKey = PaletteKey.from(air);
int emptyHeight = minBuildHeight;
String biomeName = data.biomeNames[rx][rz];
if (biomeName == null || biomeName.equals(DEFAULT_BIOME)) {
biomeName = null;
}
int emptyParams = 1;
emptyParams |= encodeHeightToParams(emptyHeight);
if (biomeName != null) {
emptyParams |= 0x100000;
}
if (!blockPalette.containsKey(paletteKey)) {
emptyParams |= 0x200000;
}
if (biomeName != null && !biomePalette.containsKey(biomeName)) {
emptyParams |= 0x400000;
}
dos.writeInt(emptyParams);
writeBlockStateRef(dos, air, blockPalette);
writeBiomeRef(dos, biomeName, biomePalette);
}
private static void writeEmptyPixel(DataOutputStream dos, MapRegionData data, int rx, int rz,
int minBuildHeight,
Map<PaletteKey, Integer> blockPalette,
Map<String, Integer> biomePalette) throws IOException {
BlockState air = XaeroBlockStateNbtWriter.AIR;
PaletteKey paletteKey = PaletteKey.from(air);
int emptyHeight = data.heightMap[rx][rz];
String biomeName = data.biomeNames[rx][rz];
if (biomeName == null || biomeName.equals(DEFAULT_BIOME)) {
biomeName = null;
}
int emptyParams = 0;
emptyParams |= 1;
emptyParams |= 15 << 8;
emptyParams |= encodeHeightToParams(emptyHeight);
if (biomeName != null) {
emptyParams |= 0x100000;
}
if (!blockPalette.containsKey(paletteKey)) {
emptyParams |= 0x200000;
}
if (biomeName != null && !biomePalette.containsKey(biomeName)) {
emptyParams |= 0x400000;
}
dos.writeInt(emptyParams);
writeBlockStateRef(dos, air, blockPalette);
writeBiomeRef(dos, biomeName, biomePalette);
}
private static void writeBiomeRef(DataOutputStream dos, String biomeName,
Map<String, Integer> biomePalette) throws IOException {
if (biomeName == null) {
return;
}
if (biomePalette.containsKey(biomeName)) {
dos.writeInt(biomePalette.get(biomeName));
} else {
dos.writeUTF(biomeName);
biomePalette.put(biomeName, biomePalette.size());
}
}
private static void writePixel(DataOutputStream dos, MapRegionData data, int rx, int rz,
Map<PaletteKey, Integer> blockPalette,
Map<String, Integer> biomePalette,
BlockPropertyLookup blockLookup) throws IOException {
BlockState blockState = data.blockStates[rx][rz];
if (blockState == null) {
blockState = new BlockState(DEFAULT_BLOCK, Map.of());
}
String blockName = blockState.name();
PaletteKey paletteKey = PaletteKey.from(blockState);
int height = data.heightMap[rx][rz];
int topY = data.topBlockY[rx][rz];
int topHeight = (topY >= 0) ? topY : height;
String biomeName = data.biomeNames[rx][rz];
if (biomeName == null || biomeName.equals(DEFAULT_BIOME)) {
biomeName = null;
}
int light = data.lightMap[rx][rz];
List<OverlayEntry> overlays = data.overlays.get(rx * REGION_SIZE_BLOCKS + rz);
boolean hasOverlays = overlays != null && !overlays.isEmpty();
boolean isGrass = blockLookup.isGrassBlock(blockName);
boolean topHeightDifferent = (height != topHeight);
int params = 0;
if (!isGrass) {
params |= 1;
}
if (hasOverlays) {
params |= 2;
}
params |= light << 8;
params |= encodeHeightToParams(height);
if (biomeName != null) {
params |= 0x100000;
}
if (topHeightDifferent) {
params |= 0x1000000;
}
if (!isGrass && !blockPalette.containsKey(paletteKey)) {
params |= 0x200000;
}
if (biomeName != null && !biomePalette.containsKey(biomeName)) {
params |= 0x400000;
}
dos.writeInt(params);
if (!isGrass) {
writeBlockStateRef(dos, blockState, blockPalette);
}
if (topHeightDifferent) {
dos.writeByte(topHeight & 0xFF);
}
if (hasOverlays) {
dos.writeByte(overlays.size());
for (OverlayEntry overlay : overlays) {
serializeOverlay(overlay, dos, blockPalette, blockLookup);
}
}
writeBiomeRef(dos, biomeName, biomePalette);
}
private static void writeBlockStateRef(DataOutputStream dos, BlockState blockState,
Map<PaletteKey, Integer> blockPalette) throws IOException {
PaletteKey paletteKey = PaletteKey.from(blockState);
if (blockPalette.containsKey(paletteKey)) {
dos.writeInt(blockPalette.get(paletteKey));
} else {
XaeroBlockStateNbtWriter.writeBlockState(blockState, dos);
blockPalette.put(paletteKey, blockPalette.size());
}
}
private static int encodeHeightToParams(int height) {
return (height & 0xFF) << 12 | ((height >> 8) & 0xF) << 25;
}
private static void serializeOverlay(OverlayEntry overlay, DataOutputStream dos,
Map<PaletteKey, Integer> blockPalette,
BlockPropertyLookup blockLookup) throws IOException {
BlockState blockState = overlay.blockState;
boolean isWater = blockLookup.isWater(blockState.name());
int opacity = overlay.opacity;
int light = overlay.light;
PaletteKey paletteKey = PaletteKey.from(blockState);
int overlayParams = 0;
if (!isWater) {
overlayParams |= 1;
}
overlayParams |= light << 4;
overlayParams |= opacity << 11;
if (!isWater && !blockPalette.containsKey(paletteKey)) {
overlayParams |= 0x400;
}
dos.writeInt(overlayParams);
if (!isWater) {
writeBlockStateRef(dos, blockState, blockPalette);
}
}
}
@@ -0,0 +1,77 @@
package com.mapsyncer.mca.convert.io;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
/**
* 写入 Xaero MapSaveLoad / NbtUtils.writeBlockState 风格的方块状态 NBT。
*
* <p>格式:根 Compound → Name (string) + Properties (compound,属性按字母序)</p>
*/
public final class XaeroBlockStateNbtWriter {
public static final BlockState AIR = new BlockState("minecraft:air", Map.of());
public static final BlockState WATER = new BlockState("minecraft:water", Map.of());
private static final ConcurrentHashMap<BlockState, PaletteKey> PALETTE_KEY_CACHE = new ConcurrentHashMap<>();
private XaeroBlockStateNbtWriter() {}
/**
* Region 内 block palette 键:名称 + 按字母序排列的属性,对齐 Xaero HashMap&lt;BlockState&gt; 语义。
*/
public record PaletteKey(String name, List<Map.Entry<String, String>> properties) {
public static PaletteKey from(BlockState state) {
if (state == null) {
return from(AIR);
}
return PALETTE_KEY_CACHE.computeIfAbsent(state, s -> {
TreeMap<String, String> sorted = new TreeMap<>(s.properties());
return new PaletteKey(s.name(), List.copyOf(sorted.entrySet()));
});
}
public BlockState toBlockState() {
if (properties.isEmpty()) {
return new BlockState(name, Map.of());
}
var map = new java.util.LinkedHashMap<String, String>();
for (Map.Entry<String, String> e : properties) {
map.put(e.getKey(), e.getValue());
}
return new BlockState(name, Collections.unmodifiableMap(map));
}
}
public static void writeBlockState(BlockState state, DataOutputStream dos) throws IOException {
BlockState effective = state != null ? state : AIR;
dos.writeByte(10);
dos.writeShort(0);
dos.writeByte(8);
dos.writeUTF("Name");
dos.writeUTF(effective.name());
if (!effective.properties().isEmpty()) {
dos.writeByte(10);
dos.writeUTF("Properties");
TreeMap<String, String> sorted = new TreeMap<>(effective.properties());
for (Map.Entry<String, String> entry : sorted.entrySet()) {
dos.writeByte(8);
dos.writeUTF(entry.getKey());
dos.writeUTF(entry.getValue());
}
dos.writeByte(0);
}
dos.writeByte(0);
}
}
@@ -0,0 +1,18 @@
package com.mapsyncer.mca.convert.model;
public final class ConvertConstants {
public static final String DEFAULT_BLOCK = "minecraft:air";
public static final String DEFAULT_BIOME = "minecraft:the_void";
public static final int REGION_SIZE_BLOCKS = 512;
public static final int CHUNKS_PER_REGION = 32;
public static final int BLOCKS_PER_TILE_CHUNK = 64;
public static final int BLOCKS_PER_TILE = 16;
public static final int TILES_PER_TILE_CHUNK = 4;
public static final int TILE_CHUNKS_PER_REGION = 8;
public static final int MAJOR_VERSION = 6;
public static final int MINOR_VERSION = 8;
private ConvertConstants() {}
}
@@ -0,0 +1,66 @@
package com.mapsyncer.mca.convert.model;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.RegionConverterStandalone.CaveModeParams;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static com.mapsyncer.mca.convert.model.ConvertConstants.CHUNKS_PER_REGION;
import static com.mapsyncer.mca.convert.model.ConvertConstants.REGION_SIZE_BLOCKS;
public class MapRegionData {
public final BlockState[][] blockStates;
public final int[][] topBlockY;
public final String[][] biomeNames;
public final int[][] heightMap;
public final byte[][] lightMap;
public final boolean[][] hasData;
public final boolean[][] chunkExists;
public final Map<Integer, List<OverlayEntry>> overlays;
public final int minBuildHeight;
public final LightMode lightMode;
public final CaveModeParams caveParams;
public final ChunkDataParser.ChunkInfo[][] chunkGrid;
public MapRegionData(int minBuildHeight, LightMode lightMode) {
this(minBuildHeight, lightMode, CaveModeParams.NONE);
}
public MapRegionData(int minBuildHeight, LightMode lightMode, CaveModeParams caveParams) {
this.minBuildHeight = minBuildHeight;
this.lightMode = lightMode;
this.caveParams = caveParams != null ? caveParams : CaveModeParams.NONE;
blockStates = new BlockState[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
topBlockY = new int[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
for (int x = 0; x < REGION_SIZE_BLOCKS; x++) {
Arrays.fill(topBlockY[x], -1);
}
biomeNames = new String[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
heightMap = new int[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
for (int x = 0; x < REGION_SIZE_BLOCKS; x++) {
Arrays.fill(heightMap[x], minBuildHeight);
}
lightMap = new byte[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
hasData = new boolean[REGION_SIZE_BLOCKS][REGION_SIZE_BLOCKS];
chunkExists = new boolean[CHUNKS_PER_REGION][CHUNKS_PER_REGION];
overlays = new HashMap<>();
chunkGrid = new ChunkDataParser.ChunkInfo[CHUNKS_PER_REGION][CHUNKS_PER_REGION];
}
/** 是否至少有一个 tile 被扫描写入(非空 region)。 */
public boolean hasAnyMapData() {
for (int x = 0; x < REGION_SIZE_BLOCKS; x++) {
for (int z = 0; z < REGION_SIZE_BLOCKS; z++) {
if (hasData[x][z]) {
return true;
}
}
}
return false;
}
}
@@ -0,0 +1,21 @@
package com.mapsyncer.mca.convert.model;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
public class OverlayEntry {
public final BlockState blockState;
public final int y;
public int opacity;
public final int light;
public OverlayEntry(BlockState blockState, int y, int opacity, int light) {
this.blockState = blockState;
this.y = y;
this.opacity = opacity;
this.light = light;
}
public String blockName() {
return blockState.name();
}
}
@@ -0,0 +1,58 @@
package com.mapsyncer.mca.convert.overlay;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
import com.mapsyncer.mca.convert.io.XaeroBlockStateNbtWriter;
import com.mapsyncer.mca.convert.model.OverlayEntry;
import java.util.ArrayList;
import java.util.List;
public final class OverlayAccumulator {
public static final int MAX_LAYERS = 10;
private OverlayAccumulator() {}
public static void add(List<OverlayEntry> currentList, ArrayList<OverlayEntry> list,
BlockState blockState, int y, int opacityToAdd, int light,
BlockPropertyLookup blockLookup) {
if (currentList != list) {
addSingle(list, blockState, y, opacityToAdd, light, blockLookup);
return;
}
if (list.size() >= MAX_LAYERS) {
return;
}
opacityToAdd = normalizeOpacity(blockState.name(), opacityToAdd, blockLookup);
OverlayEntry last = list.isEmpty() ? null : list.get(list.size() - 1);
if (last != null && XaeroBlockStateNbtWriter.PaletteKey.from(last.blockState)
.equals(XaeroBlockStateNbtWriter.PaletteKey.from(blockState))) {
last.opacity = Math.min(15, last.opacity + opacityToAdd);
} else {
list.add(new OverlayEntry(blockState, y, opacityToAdd, light));
}
}
private static void addSingle(ArrayList<OverlayEntry> list, BlockState blockState, int y,
int opacityToAdd, int light, BlockPropertyLookup blockLookup) {
if (list.size() >= MAX_LAYERS) {
return;
}
opacityToAdd = normalizeOpacity(blockState.name(), opacityToAdd, blockLookup);
list.add(new OverlayEntry(blockState, y, opacityToAdd, light));
}
private static int normalizeOpacity(String blockName, int opacityToAdd, BlockPropertyLookup blockLookup) {
if (opacityToAdd > 15) {
opacityToAdd = 15;
}
if (opacityToAdd == 0 && !blockLookup.isWater(blockName)) {
String lower = blockName.toLowerCase();
if (lower.contains("seagrass") || lower.contains("kelp") || blockLookup.isTransparent(blockName)) {
opacityToAdd = 1;
}
}
return opacityToAdd;
}
}
@@ -0,0 +1,143 @@
package com.mapsyncer.mca.convert.scan;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkSectionParser;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.RegionConverterStandalone;
import com.mapsyncer.mca.convert.model.MapRegionData;
import static com.mapsyncer.mca.convert.model.ConvertConstants.REGION_SIZE_BLOCKS;
public final class ChunkColumnScanner {
private ChunkColumnScanner() {}
public static void scan(MapRegionData data,
ChunkDataParser.ChunkInfo chunk,
int minBuildHeight,
int worldTopY,
LightMode lightMode,
RegionConverterStandalone.CaveModeParams caveParams,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup) {
scan(data, chunk, minBuildHeight, worldTopY, lightMode, caveParams, worldHasSkylight,
blockLookup, ScanVerticalBounds.unbounded());
}
public static void scan(MapRegionData data,
ChunkDataParser.ChunkInfo chunk,
int minBuildHeight,
int worldTopY,
LightMode lightMode,
RegionConverterStandalone.CaveModeParams caveParams,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup,
ScanVerticalBounds bounds) {
int chunkX = chunk.chunkX();
int chunkZ = chunk.chunkZ();
data.chunkExists[chunkX][chunkZ] = true;
data.chunkGrid[chunkX][chunkZ] = chunk;
int caveStart = caveParams.caveStart();
int caveDepth = caveParams.caveDepth();
boolean isCaveMode = caveStart != Integer.MAX_VALUE;
boolean fullCave = caveStart == Integer.MIN_VALUE;
int[][] heightMap = chunk.heightmap();
int chunkBottomY = chunk.chunkBottomY();
ColumnScanContext ctx = new ColumnScanContext(fullCave);
int sectionIndex = 0;
for (ChunkSectionParser.SectionData section : chunk.sections()) {
if (section.blockPalette().isEmpty()) {
continue;
}
int sectionY = section.sectionY();
int sectionBaseY = sectionY * 16;
int sectionTopY = sectionBaseY + 15;
int sectionBottomY = sectionBaseY;
if (sectionTopY < chunkBottomY) {
continue;
}
boolean singlePalette = section.blockPalette().size() == 1 && section.blockData() == null;
ChunkSectionParser.BlockState singleState = singlePalette
? section.blockPalette().get(0) : null;
for (int lx = 0; lx < 16; lx++) {
for (int lz = 0; lz < 16; lz++) {
int relX = chunkX * 16 + lx;
int relZ = chunkZ * 16 + lz;
if (relX >= REGION_SIZE_BLOCKS || relZ >= REGION_SIZE_BLOCKS) {
continue;
}
int pos = ColumnScanContext.pos(lx, lz);
if (ctx.blockFound[pos]) {
continue;
}
int heightMapValue = heightMap[lx][lz];
int scanBottomY;
int startY;
if (isCaveMode) {
startY = bounds.clampStartY(caveStart);
scanBottomY = bounds.clampBottomY(minBuildHeight,
Math.max(caveStart - caveDepth, minBuildHeight));
} else {
startY = bounds.resolveSurfaceStartY(
ChunkDataParser.getHeightmapStartY(chunk, lx, lz, worldTopY));
scanBottomY = bounds.clampBottomY(minBuildHeight, minBuildHeight);
}
if (startY < scanBottomY) {
continue;
}
if (isCaveMode && sectionTopY > startY) {
continue;
}
// 整段在扫描底以下才跳过(用 sectionTopY,不能用 sectionBottomY
if (sectionTopY < scanBottomY) {
continue;
}
int effectiveStartY = computeEffectiveStartY(sectionIndex, startY, worldTopY,
isCaveMode, heightMapValue, chunkBottomY, sectionTopY, bounds);
if (!isCaveMode && effectiveStartY < sectionBottomY) {
continue;
}
PixelColumnProcessor.processColumn(chunk, section, sectionBaseY,
lx, lz, relX, relZ, effectiveStartY, scanBottomY, chunkBottomY,
heightMapValue, isCaveMode, worldHasSkylight, lightMode,
singlePalette, singleState, ctx, data, blockLookup);
}
}
sectionIndex++;
}
}
private static int computeEffectiveStartY(int sectionIndex, int startY, int worldTopY,
boolean isCaveMode, int heightMapValue, int chunkBottomY,
int sectionTopY, ScanVerticalBounds bounds) {
int effectiveStartY = startY;
if (sectionIndex > 0) {
effectiveStartY = Math.min(startY + 1, worldTopY - 1);
}
if (!isCaveMode && !bounds.ignoresHeightmap() && heightMapValue < chunkBottomY) {
effectiveStartY = sectionTopY;
}
if (isCaveMode) {
effectiveStartY = Math.min(effectiveStartY, sectionTopY);
}
return effectiveStartY;
}
}
@@ -0,0 +1,57 @@
package com.mapsyncer.mca.convert.scan;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkSectionParser;
import com.mapsyncer.mca.convert.model.OverlayEntry;
import java.util.ArrayList;
public final class ColumnScanContext {
public final boolean[] blockFound = new boolean[256];
public final boolean[] underair = new boolean[256];
/** 洞穴模式:扫描起点处尚未进入洞穴内部(参考 Xaero shouldEnterGround */
public final boolean[] shouldEnterGround = new boolean[256];
@SuppressWarnings("unchecked")
public final ArrayList<OverlayEntry>[] overlayLists = new ArrayList[256];
public final int[] topPixelH = new int[256];
public ColumnScanContext(boolean fullCave) {
for (int i = 0; i < 256; i++) {
underair[i] = fullCave;
shouldEnterGround[i] = fullCave;
topPixelH[i] = -1;
}
}
/** 进入空气区域(Xaero: 遇 air 设 underair=true */
void onAir(int pos) {
underair[pos] = true;
shouldEnterGround[pos] = false;
}
/**
* 流体触发 underairXaero MapWriter: 除非 cave && shouldEnterGround,否则设 underair
*/
void onFluid(int pos, boolean isCaveMode) {
if (!isCaveMode || !shouldEnterGround[pos]) {
underair[pos] = true;
}
}
/** 洞穴模式:只有 underair 后才可记录实体方块/overlay */
boolean canProcessCaveBlock(int pos, boolean isCaveMode) {
return !isCaveMode || underair[pos];
}
static boolean hasFluid(ChunkSectionParser.BlockState state, BlockPropertyLookup lookup) {
if (state.isFluid() || state.isWaterlogged()) {
return true;
}
return (lookup.getFlags(state.name()) & BlockPropertyLookup.FLAG_TRANSLUCENT_FLUID) != 0;
}
public static int pos(int lx, int lz) {
return (lz << 4) | lx;
}
}
@@ -0,0 +1,265 @@
package com.mapsyncer.mca.convert.scan;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkSectionParser;
import com.mapsyncer.mca.ChunkSectionParser.BlockState;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.convert.io.XaeroBlockStateNbtWriter;
import com.mapsyncer.mca.convert.model.MapRegionData;
import com.mapsyncer.mca.convert.model.OverlayEntry;
import com.mapsyncer.mca.convert.overlay.OverlayAccumulator;
import java.util.ArrayList;
import java.util.List;
import static com.mapsyncer.mca.convert.model.ConvertConstants.REGION_SIZE_BLOCKS;
/**
* 统一的列扫描逻辑,合并原 single/multi palette 路径。
*/
public final class PixelColumnProcessor {
private PixelColumnProcessor() {}
/**
* @return true 表示该像素已找到表面
*/
public static boolean processColumn(
ChunkDataParser.ChunkInfo chunk,
ChunkSectionParser.SectionData section,
int sectionBaseY,
int lx, int lz,
int relX, int relZ,
int effectiveStartY, int scanBottomY,
int chunkBottomY,
int heightMapValue,
boolean isCaveMode,
boolean worldHasSkylight,
LightMode lightMode,
boolean singlePalette,
ChunkSectionParser.BlockState singleState,
ColumnScanContext ctx,
MapRegionData data,
BlockPropertyLookup blockLookup) {
int pos = ColumnScanContext.pos(lx, lz);
if (ctx.blockFound[pos]) {
return false;
}
if (singlePalette) {
if (singleState.isAir()) {
if (isCaveMode) {
ctx.onAir(pos);
}
return false;
}
if (isCaveMode && ColumnScanContext.hasFluid(singleState, blockLookup)) {
ctx.onFluid(pos, true);
}
if (!ctx.canProcessCaveBlock(pos, isCaveMode)) {
return false;
}
}
int localStartY = 15;
if (effectiveStartY >= sectionBaseY && effectiveStartY <= sectionBaseY + 15) {
localStartY = effectiveStartY - sectionBaseY;
} else if (singlePalette) {
localStartY = Math.min(effectiveStartY - sectionBaseY, 15);
if (localStartY < 0) {
localStartY = 15;
}
}
int localScanBottomY = Math.max(0, scanBottomY - sectionBaseY);
for (int ly = localStartY; ly >= localScanBottomY; ly--) {
int worldY = sectionBaseY + ly;
if (worldY < scanBottomY) {
break;
}
if (worldY < chunkBottomY) {
break;
}
ChunkSectionParser.BlockState state = singlePalette
? singleState
: ChunkSectionParser.getBlockStateAt(section, lx, ly, lz);
if (state.isAir()) {
if (isCaveMode) {
ctx.onAir(pos);
}
continue;
}
if (isCaveMode && ColumnScanContext.hasFluid(state, blockLookup)) {
ctx.onFluid(pos, true);
}
if (!ctx.canProcessCaveBlock(pos, isCaveMode)) {
continue;
}
String blockName = state.name();
int flags = blockLookup.getFlags(blockName);
ArrayList<OverlayEntry> overlays = ctx.overlayLists[pos];
if ((flags & BlockPropertyLookup.FLAG_WATER_INHERITING) != 0) {
return finishSurface(chunk, section, lx, ly, lz, relX, relZ, worldY,
state, heightMapValue, overlays, ctx, data, blockLookup,
lightMode, worldHasSkylight, true);
}
if (blockLookup.isWaterloggedSurface(blockName, state.properties())
&& (flags & BlockPropertyLookup.FLAG_SHOULD_OVERLAY) == 0) {
return finishSurface(chunk, section, lx, ly, lz, relX, relZ, worldY,
state, heightMapValue, overlays, ctx, data, blockLookup,
lightMode, worldHasSkylight, false);
}
if ((flags & BlockPropertyLookup.FLAG_TRANSLUCENT_FLUID) != 0) {
addFluidOverlay(chunk, section, lx, ly, lz, worldY, state,
overlays, ctx, pos, blockLookup);
continue;
}
if (state.isWaterlogged() && (flags & BlockPropertyLookup.FLAG_SHOULD_OVERLAY) != 0) {
int aboveWorldY = worldY + 1;
int waterOpacity = blockLookup.getLightBlock("minecraft:water");
byte waterLight = SectionLightAccess.getBlockLightCrossSection(
chunk, section, lx, ly, lz, aboveWorldY);
overlays = ensureOverlayList(ctx, pos, overlays);
OverlayAccumulator.add(overlays, overlays, XaeroBlockStateNbtWriter.WATER, worldY,
waterOpacity, waterLight, blockLookup);
int opacity = blockLookup.getLightBlock(blockName);
byte light = SectionLightAccess.getBlockLightCrossSection(
chunk, section, lx, ly, lz, aboveWorldY);
OverlayAccumulator.add(overlays, overlays, state, worldY, opacity, light, blockLookup);
if (ctx.topPixelH[pos] < 0) {
ctx.topPixelH[pos] = worldY;
}
continue;
}
if ((flags & BlockPropertyLookup.FLAG_SHOULD_OVERLAY) != 0) {
int opacity = blockLookup.getLightBlock(blockName);
int aboveWorldY = worldY + 1;
byte light = SectionLightAccess.getBlockLightCrossSection(
chunk, section, lx, ly, lz, aboveWorldY);
overlays = ensureOverlayList(ctx, pos, overlays);
OverlayAccumulator.add(overlays, overlays, state, worldY, opacity, light, blockLookup);
if (ctx.topPixelH[pos] < 0) {
ctx.topPixelH[pos] = worldY;
}
continue;
}
if ((flags & BlockPropertyLookup.FLAG_INVISIBLE) != 0) {
continue;
}
if ((flags & BlockPropertyLookup.FLAG_TRANSPARENT) != 0) {
int opacity = blockLookup.getLightBlock(blockName);
int aboveWorldY = worldY + 1;
byte light = SectionLightAccess.getBlockLightCrossSection(
chunk, section, lx, ly, lz, aboveWorldY);
overlays = ensureOverlayList(ctx, pos, overlays);
OverlayAccumulator.add(overlays, overlays, state, worldY, opacity, light, blockLookup);
if (ctx.topPixelH[pos] < 0) {
ctx.topPixelH[pos] = worldY;
}
continue;
}
int aboveWorldY = worldY + 1;
byte light = SectionLightAccess.calculateSurfaceLight(chunk, section, lx, ly, lz, aboveWorldY,
heightMapValue, overlays, lightMode, worldHasSkylight, blockLookup);
int topBlockY = ctx.topPixelH[pos] < 0 ? worldY : ctx.topPixelH[pos];
recordPixelScan(data, state, worldY, topBlockY, light, ctx.overlayLists[pos], relX, relZ);
ctx.blockFound[pos] = true;
return true;
}
return false;
}
private static boolean finishSurface(
ChunkDataParser.ChunkInfo chunk,
ChunkSectionParser.SectionData section,
int lx, int ly, int lz,
int relX, int relZ,
int worldY,
ChunkSectionParser.BlockState state,
int heightMapValue,
ArrayList<OverlayEntry> overlays,
ColumnScanContext ctx,
MapRegionData data,
BlockPropertyLookup blockLookup,
LightMode lightMode,
boolean worldHasSkylight,
boolean useCalculateLight) {
int pos = ColumnScanContext.pos(lx, lz);
int opacity = blockLookup.getLightBlock("minecraft:water");
int aboveWorldY = worldY + 1;
byte light = useCalculateLight
? SectionLightAccess.calculateSurfaceLight(chunk, section, lx, ly, lz, aboveWorldY,
heightMapValue, overlays, lightMode, worldHasSkylight, blockLookup)
: SectionLightAccess.getBlockLightCrossSection(chunk, section, lx, ly, lz, aboveWorldY);
overlays = ensureOverlayList(ctx, pos, overlays);
OverlayAccumulator.add(overlays, overlays, XaeroBlockStateNbtWriter.WATER, worldY, opacity, light, blockLookup);
int topBlockY = ctx.topPixelH[pos] < 0 ? worldY : ctx.topPixelH[pos];
recordPixelScan(data, state, worldY, topBlockY, light, ctx.overlayLists[pos], relX, relZ);
ctx.blockFound[pos] = true;
return true;
}
private static void addFluidOverlay(
ChunkDataParser.ChunkInfo chunk,
ChunkSectionParser.SectionData section,
int lx, int ly, int lz,
int worldY,
ChunkSectionParser.BlockState state,
ArrayList<OverlayEntry> overlays,
ColumnScanContext ctx,
int pos,
BlockPropertyLookup blockLookup) {
int opacity = blockLookup.getLightBlock(state.name());
int aboveWorldY = worldY + 1;
byte light = SectionLightAccess.getBlockLightCrossSection(chunk, section, lx, ly, lz, aboveWorldY);
overlays = ensureOverlayList(ctx, pos, overlays);
OverlayAccumulator.add(overlays, overlays, state, worldY, opacity, light, blockLookup);
if (ctx.topPixelH[pos] < 0) {
ctx.topPixelH[pos] = worldY;
}
}
private static ArrayList<OverlayEntry> ensureOverlayList(
ColumnScanContext ctx, int pos, ArrayList<OverlayEntry> overlays) {
if (overlays == null) {
overlays = new ArrayList<>();
ctx.overlayLists[pos] = overlays;
}
return overlays;
}
static void recordPixelScan(MapRegionData data, ChunkSectionParser.BlockState surfaceState,
int topY, int highestBlockY, byte surfaceLight,
List<OverlayEntry> overlayList, int relX, int relZ) {
if (relX >= REGION_SIZE_BLOCKS || relZ >= REGION_SIZE_BLOCKS) {
return;
}
data.hasData[relX][relZ] = true;
BlockState stored = surfaceState != null ? surfaceState : XaeroBlockStateNbtWriter.AIR;
data.blockStates[relX][relZ] = stored;
data.topBlockY[relX][relZ] = highestBlockY;
data.heightMap[relX][relZ] = topY;
data.lightMap[relX][relZ] = surfaceLight;
if (overlayList != null && !overlayList.isEmpty()) {
data.overlays.put(relX * REGION_SIZE_BLOCKS + relZ, overlayList);
}
}
}
@@ -0,0 +1,18 @@
package com.mapsyncer.mca.convert.scan;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.RegionConverterStandalone.CaveModeParams;
/**
* 单个 region 的一种扫描/输出配置(对应一个 Xaero 层或地表层)。
*/
public record RegionScanPass(
int caveLayer,
LightMode lightMode,
CaveModeParams caveParams,
ScanVerticalBounds verticalBounds
) {
public boolean isSurfaceLayer() {
return caveLayer == Integer.MAX_VALUE;
}
}
@@ -0,0 +1,43 @@
package com.mapsyncer.mca.convert.scan;
/**
* 列扫描的垂直范围限制(地表模式用于逻辑顶以上区域等场景)。
*/
public record ScanVerticalBounds(int floorY, int ceilingY) {
public static ScanVerticalBounds unbounded() {
return new ScanVerticalBounds(Integer.MIN_VALUE, Integer.MAX_VALUE);
}
public static ScanVerticalBounds fullColumn(int minBuildHeight, int worldTopY) {
return new ScanVerticalBounds(minBuildHeight, worldTopY - 1);
}
/** 仅扫描 {@code floorY}(含)以上到世界顶 */
public static ScanVerticalBounds aboveY(int floorY, int worldTopY) {
return new ScanVerticalBounds(floorY, worldTopY - 1);
}
public int clampStartY(int startY) {
return Math.min(startY, ceilingY);
}
public int clampBottomY(int minBuildHeight, int scanBottomY) {
return Math.max(scanBottomY, Math.max(minBuildHeight, floorY));
}
/**
* 地表模式起点:有 {@code floorY} 限制时忽略高度图,从 {@code ceilingY} 向下扫
* (地狱逻辑顶以上地表;高度图指向下层可玩区,不能用于上层扫描起点)。
*/
public int resolveSurfaceStartY(int heightmapStartY) {
if (floorY > Integer.MIN_VALUE) {
return ceilingY;
}
return clampStartY(heightmapStartY);
}
public boolean ignoresHeightmap() {
return floorY > Integer.MIN_VALUE;
}
}
@@ -0,0 +1,98 @@
package com.mapsyncer.mca.convert.scan;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkDataParser;
import com.mapsyncer.mca.ChunkSectionParser;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.convert.model.OverlayEntry;
import java.util.List;
public final class SectionLightAccess {
private SectionLightAccess() {}
public static ChunkSectionParser.SectionData findSectionAt(ChunkDataParser.ChunkInfo chunk, int worldY) {
ChunkSectionParser.SectionData[] lookup = chunk.sectionLookup();
if (lookup == null) {
return null;
}
int idx = (worldY >> 4) - chunk.minSectionY();
if (idx >= 0 && idx < lookup.length) {
return lookup[idx];
}
return null;
}
public static byte getBlockLightCrossSection(ChunkDataParser.ChunkInfo chunk,
ChunkSectionParser.SectionData currentSection,
int lx, int ly, int lz, int worldY) {
int sectionY = worldY >> 4;
if (sectionY == currentSection.sectionY()) {
int localY = worldY - (sectionY * 16);
if (localY >= 0 && localY <= 15) {
return ChunkSectionParser.getBlockLight(currentSection, lx, localY, lz);
}
}
ChunkSectionParser.SectionData targetSection = findSectionAt(chunk, worldY);
if (targetSection != null) {
int localY = worldY - (targetSection.sectionY() * 16);
return ChunkSectionParser.getBlockLight(targetSection, lx, localY, lz);
}
return 0;
}
public static byte calculateSurfaceLight(ChunkDataParser.ChunkInfo chunk,
ChunkSectionParser.SectionData currentSection,
int lx, int ly, int lz, int worldY,
int heightMapValue,
List<OverlayEntry> overlayList,
LightMode lightMode,
boolean worldHasSkylight,
BlockPropertyLookup blockLookup) {
byte blockLight = getBlockLightCrossSection(chunk, currentSection, lx, ly, lz, worldY);
byte skyLight = 0;
ChunkSectionParser.SectionData stateSection = null;
int worldYSkySectionY = worldY >> 4;
if (worldYSkySectionY == currentSection.sectionY()) {
int localY = worldY - (worldYSkySectionY * 16);
if (localY >= 0 && localY <= 15) {
skyLight = ChunkSectionParser.getSkyLight(currentSection, lx, localY, lz);
}
} else {
stateSection = findSectionAt(chunk, worldY);
if (stateSection != null) {
int localY = worldY - (stateSection.sectionY() * 16);
skyLight = ChunkSectionParser.getSkyLight(stateSection, lx, localY, lz);
}
}
boolean hasFluidOverlay = false;
if (overlayList != null) {
for (OverlayEntry o : overlayList) {
if (blockLookup.isWater(o.blockName())) {
hasFluidOverlay = true;
break;
}
}
}
boolean hasSkyAccess = worldY >= heightMapValue;
if (stateSection == null) {
stateSection = findSectionAt(chunk, worldY);
}
if (stateSection == null) {
stateSection = currentSection;
}
int stateLocalY = worldY - (stateSection.sectionY() * 16);
if (stateLocalY < 0 || stateLocalY > 15) {
stateLocalY = ly;
}
boolean isGlowing = blockLookup.isGlowing(
ChunkSectionParser.getBlockStateAt(stateSection, lx, stateLocalY, lz).name());
return lightMode.calculateEffectiveLight(
blockLight, skyLight, hasSkyAccess, hasFluidOverlay, isGlowing, worldHasSkylight);
}
}
@@ -0,0 +1,292 @@
package com.mapsyncer.nbt;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* NBT读取器 - 零依赖实现
*
* <p>用于解析Minecraft NBTNamed Binary Tag)格式的二进制数据。
* 所有数据采用大端序(Big-Endian)存储,符合Minecraft的NBT规范。</p>
*
* <p>安全限制:为防止恶意NBT数据导致内存溢出,对数组大小、列表长度和嵌套深度设有限制。
* 如遇到超出限制的数据,请将日志信息汇报给开发者以便分析。</p>
*
* <p>使用示例:</p>
* <pre>{@code
* try (NbtReader reader = new NbtReader(inputStream)) {
* Tag.Compound root = reader.readDocument();
* // 处理NBT数据...
* }
* }</pre>
*
* @see Tag
*/
public class NbtReader implements AutoCloseable {
private static final Logger LOGGER = LoggerFactory.getLogger(NbtReader.class);
/**
* 数组大小上限(ByteArray/IntArray/LongArray
*
* <p>实际最大需求约 25,000(单区块所有section的block_states.data),
* 此限制设为5倍 = 125,000,足够容纳正常数据。</p>
*/
private static final int MAX_ARRAY_SIZE = 125_000;
/**
* 列表长度上限
*
* <p>原版 block palette 约 1000,大型模组包可能达到 10,000-20,000+
* 此限制设为 100,000,足够容纳极端 Mod 环境。</p>
*/
private static final int MAX_LIST_SIZE = 100_000;
/**
* Compound嵌套深度上限
*
* <p>实际最大需求约 5-6层(Chunk→sections→section→block_states→palette),
* 此限制设为5倍 = 30层,足够容纳正常数据。</p>
*/
private static final int MAX_COMPOUND_DEPTH = 30;
/** 数据输入流,用于读取二进制NBT数据 */
private final DataInputStream in;
/** 当前嵌套深度 */
private int currentDepth = 0;
/**
* 构造NBT读取器
*
* @param in 输入流,包含NBT格式的二进制数据
*/
public NbtReader(InputStream in) {
this.in = new DataInputStream(in);
}
/**
* 读取完整的NBT文档(根Compound
*
* <p>读取整个NBT文档,返回根Compound标签。
* NBT文档必须以Compound类型开头。</p>
*
* @return 根Compound标签
* @throws IOException 如果读取失败或文档格式不正确
*/
public Tag.Compound readDocument() throws IOException {
byte type = in.readByte();
if (type != Tag.TAG_COMPOUND) {
throw new IOException("NBT文档必须以Compound开头,实际类型: " + type);
}
String name = in.readUTF();
return readCompoundContent(name);
}
/**
* 读取单个Tag(包含类型和名称)
*
* <p>从输入流中读取一个完整的标签,包括类型标识、名称和数据内容。</p>
*
* @return 读取的Tag对象
* @throws IOException 如果读取失败
*/
public Tag readTag() throws IOException {
byte type = in.readByte();
if (type == Tag.TAG_END) {
return new Tag.End();
}
String name = in.readUTF();
return readPayload(type, name);
}
/**
* 读取Tag内容(不含类型和名称前缀)
*
* <p>根据给定的类型标识读取对应的数据内容。</p>
*
* @param type NBT类型标识
* @param name 标签名称
* @return 读取的Tag对象
* @throws IOException 如果读取失败或类型未知
*/
private Tag readPayload(byte type, String name) throws IOException {
switch (type) {
case Tag.TAG_END:
return new Tag.End();
case Tag.TAG_BYTE:
return new Tag.Byte(name, in.readByte());
case Tag.TAG_SHORT:
return new Tag.Short(name, in.readShort());
case Tag.TAG_INT:
return new Tag.Int(name, in.readInt());
case Tag.TAG_LONG:
return new Tag.Long(name, in.readLong());
case Tag.TAG_FLOAT:
return new Tag.Float(name, in.readFloat());
case Tag.TAG_DOUBLE:
return new Tag.Double(name, in.readDouble());
case Tag.TAG_BYTE_ARRAY:
return readByteArray(name);
case Tag.TAG_STRING:
return new Tag.StringTag(name, in.readUTF());
case Tag.TAG_LIST:
return readListContent(name);
case Tag.TAG_COMPOUND:
return readCompoundContent(name);
case Tag.TAG_INT_ARRAY:
return readIntArray(name);
case Tag.TAG_LONG_ARRAY:
return readLongArray(name);
default:
throw new IOException("未知NBT类型: " + type);
}
}
/**
* 读取ByteArray类型标签
*
* @param name 标签名称
* @return ByteArray标签对象
* @throws IOException 如果读取失败或长度超限
*/
private Tag.ByteArray readByteArray(String name) throws IOException {
int length = in.readInt();
if (length < 0) {
throw new IOException("ByteArray长度不能为负: " + length);
}
if (length > MAX_ARRAY_SIZE) {
LOGGER.warn("NBT size limit exceeded: ByteArray '{}' length={}, max={}. " +
"Please report this with the MCA file location for analysis.", name, length, MAX_ARRAY_SIZE);
throw new IOException("ByteArray长度超限: " + length + " (最大 " + MAX_ARRAY_SIZE + ")");
}
byte[] data = new byte[length];
in.readFully(data);
return new Tag.ByteArray(name, data);
}
/**
* 读取IntArray类型标签
*
* @param name 标签名称
* @return IntArray标签对象
* @throws IOException 如果读取失败或长度超限
*/
private Tag.IntArray readIntArray(String name) throws IOException {
int length = in.readInt();
if (length < 0) {
throw new IOException("IntArray长度不能为负: " + length);
}
if (length > MAX_ARRAY_SIZE) {
LOGGER.warn("NBT size limit exceeded: IntArray '{}' length={}, max={}. " +
"Please report this with the MCA file location for analysis.", name, length, MAX_ARRAY_SIZE);
throw new IOException("IntArray长度超限: " + length + " (最大 " + MAX_ARRAY_SIZE + ")");
}
int[] data = new int[length];
for (int i = 0; i < length; i++) {
data[i] = in.readInt();
}
return new Tag.IntArray(name, data);
}
/**
* 读取LongArray类型标签
*
* @param name 标签名称
* @return LongArray标签对象
* @throws IOException 如果读取失败或长度超限
*/
private Tag.LongArray readLongArray(String name) throws IOException {
int length = in.readInt();
if (length < 0) {
throw new IOException("LongArray长度不能为负: " + length);
}
if (length > MAX_ARRAY_SIZE) {
LOGGER.warn("NBT size limit exceeded: LongArray '{}' length={}, max={}. " +
"Please report this with the MCA file location for analysis.", name, length, MAX_ARRAY_SIZE);
throw new IOException("LongArray长度超限: " + length + " (最大 " + MAX_ARRAY_SIZE + ")");
}
long[] data = new long[length];
for (int i = 0; i < length; i++) {
data[i] = in.readLong();
}
return new Tag.LongArray(name, data);
}
/**
* 读取List类型标签内容
*
* <p>List中的所有元素必须是相同类型。元素没有名称,使用空字符串作为名称。</p>
*
* @param name 标签名称
* @return ListTag标签对象
* @throws IOException 如果读取失败或长度超限
*/
private Tag.ListTag readListContent(String name) throws IOException {
byte elementType = in.readByte();
int length = in.readInt();
if (length < 0) {
throw new IOException("List长度不能为负: " + length);
}
if (length > MAX_LIST_SIZE) {
LOGGER.warn("NBT size limit exceeded: List '{}' length={}, max={}. " +
"Please report this with the MCA file location for analysis.", name, length, MAX_LIST_SIZE);
throw new IOException("List长度超限: " + length + " (最大 " + MAX_LIST_SIZE + ")");
}
List<Tag> items = new ArrayList<>(length);
for (int i = 0; i < length; i++) {
// List元素没有名称,传入空字符串
items.add(readPayload(elementType, ""));
}
return new Tag.ListTag(name, elementType, items);
}
/**
* 读取Compound类型标签内容
*
* <p>Compound是一个键值对集合,以TAG_END作为结束标记。
* 子标签按读取顺序保存。</p>
*
* <p>检查嵌套深度,防止无限递归。</p>
*
* @param name 标签名称
* @return Compound标签对象
* @throws IOException 如果读取失败或嵌套深度超限
*/
private Tag.Compound readCompoundContent(String name) throws IOException {
currentDepth++;
if (currentDepth > MAX_COMPOUND_DEPTH) {
LOGGER.warn("NBT depth limit exceeded: Compound '{}' depth={}, max={}. " +
"Please report this with the MCA file location for analysis.", name, currentDepth, MAX_COMPOUND_DEPTH);
throw new IOException("Compound嵌套深度超限: " + currentDepth + " (最大 " + MAX_COMPOUND_DEPTH + ")");
}
Map<String, Tag> children = new LinkedHashMap<>();
while (true) {
byte type = in.readByte();
if (type == Tag.TAG_END) {
currentDepth--;
break;
}
String childName = in.readUTF();
children.put(childName, readPayload(type, childName));
}
return new Tag.Compound(name, children);
}
/**
* 关闭读取器并释放资源
*
* @throws IOException 如果关闭时发生I/O错误
*/
@Override
public void close() throws IOException {
in.close();
}
}
+369
View File
@@ -0,0 +1,369 @@
package com.mapsyncer.nbt;
import java.util.List;
import java.util.Map;
/**
* NBT标签类型定义 - 零依赖实现
*
* <p>定义了所有Minecraft NBTNamed Binary Tag)格式中使用的标签类型。
* 使用Java 17的sealed interface确保类型安全。</p>
*
* <p>支持的标签类型:</p>
* <ul>
* <li>{@link End} - 结束标记,用于标记Compound的结束</li>
* <li>{@link Byte} - 8位有符号整数</li>
* <li>{@link Short} - 16位有符号整数</li>
* <li>{@link Int} - 32位有符号整数</li>
* <li>{@link Long} - 64位有符号整数</li>
* <li>{@link Float} - 32位IEEE 754浮点数</li>
* <li>{@link Double} - 64位IEEE 754浮点数</li>
* <li>{@link ByteArray} - 字节数组</li>
* <li>{@link StringTag} - UTF-8字符串</li>
* <li>{@link ListTag} - 同类型标签列表</li>
* <li>{@link Compound} - 键值对集合</li>
* <li>{@link IntArray} - 整数数组</li>
* <li>{@link LongArray} - 长整数数组</li>
* </ul>
*
* @see NbtReader
*/
public sealed interface Tag permits
Tag.End,
Tag.Byte,
Tag.Short,
Tag.Int,
Tag.Long,
Tag.Float,
Tag.Double,
Tag.ByteArray,
Tag.StringTag,
Tag.ListTag,
Tag.Compound,
Tag.IntArray,
Tag.LongArray {
/**
* 获取NBT标签类型ID
*
* @return 标签类型ID0-12
*/
byte typeId();
/**
* 获取标签名称
*
* <p>根Compound通常为空字符串。</p>
*
* @return 标签名称
*/
String name();
// ========== 标签类型常量 ==========
/** 结束标记类型ID - 用于标记Compound的结束 */
byte TAG_END = 0;
/** 字节类型ID - 8位有符号整数 */
byte TAG_BYTE = 1;
/** 短整型类型ID - 16位有符号整数 */
byte TAG_SHORT = 2;
/** 整型类型ID - 32位有符号整数 */
byte TAG_INT = 3;
/** 长整型类型ID - 64位有符号整数 */
byte TAG_LONG = 4;
/** 单精度浮点类型ID - 32位IEEE 754浮点数 */
byte TAG_FLOAT = 5;
/** 双精度浮点类型ID - 64位IEEE 754浮点数 */
byte TAG_DOUBLE = 6;
/** 字节数组类型ID */
byte TAG_BYTE_ARRAY = 7;
/** 字符串类型ID - UTF-8编码 */
byte TAG_STRING = 8;
/** 列表类型ID - 同类型元素集合 */
byte TAG_LIST = 9;
/** 复合类型ID - 键值对集合 */
byte TAG_COMPOUND = 10;
/** 整数数组类型ID */
byte TAG_INT_ARRAY = 11;
/** 长整数数组类型ID */
byte TAG_LONG_ARRAY = 12;
// ========== 具体Tag实现 ==========
/**
* TAG_End - Compound结束标记
*
* <p>用于标记Compound标签的结束,不包含实际数据。</p>
*/
record End() implements Tag {
@Override public byte typeId() { return TAG_END; }
@Override public String name() { return ""; }
}
/**
* TAG_Byte - 8位有符号整数
*
* @param name 标签名称
* @param value 字节值(-128到127
*/
record Byte(String name, byte value) implements Tag {
@Override public byte typeId() { return TAG_BYTE; }
}
/**
* TAG_Short - 16位有符号整数
*
* @param name 标签名称
* @param value 短整型值(-32768到32767
*/
record Short(String name, short value) implements Tag {
@Override public byte typeId() { return TAG_SHORT; }
}
/**
* TAG_Int - 32位有符号整数
*
* @param name 标签名称
* @param value 整型值
*/
record Int(String name, int value) implements Tag {
@Override public byte typeId() { return TAG_INT; }
}
/**
* TAG_Long - 64位有符号整数
*
* @param name 标签名称
* @param value 长整型值
*/
record Long(String name, long value) implements Tag {
@Override public byte typeId() { return TAG_LONG; }
}
/**
* TAG_Float - 32位IEEE 754浮点数
*
* @param name 标签名称
* @param value 单精度浮点值
*/
record Float(String name, float value) implements Tag {
@Override public byte typeId() { return TAG_FLOAT; }
}
/**
* TAG_Double - 64位IEEE 754浮点数
*
* @param name 标签名称
* @param value 双精度浮点值
*/
record Double(String name, double value) implements Tag {
@Override public byte typeId() { return TAG_DOUBLE; }
}
/**
* TAG_Byte_Array - 字节数组
*
* @param name 标签名称
* @param value 字节数组
*/
record ByteArray(String name, byte[] value) implements Tag {
@Override public byte typeId() { return TAG_BYTE_ARRAY; }
}
/**
* TAG_String - UTF-8字符串
*
* @param name 标签名称
* @param value 字符串内容
*/
record StringTag(String name, String value) implements Tag {
@Override public byte typeId() { return TAG_STRING; }
}
/**
* TAG_List - 同类型标签列表
*
* <p>List中的所有元素必须是相同的类型。元素没有独立的名称。</p>
*
* @param name 标签名称
* @param elementType 列表元素的类型ID
* @param items 标签元素列表
*/
record ListTag(String name, byte elementType, List<Tag> items) implements Tag {
@Override public byte typeId() { return TAG_LIST; }
}
/**
* TAG_Compound - 键值对集合
*
* <p>Compound是最常用的NBT类型,类似于Map结构。
* 每个子标签都有一个唯一的名称作为键。</p>
*
* @param name 标签名称
* @param children 子标签映射,键为标签名称
*/
record Compound(String name, Map<String, Tag> children) implements Tag {
@Override public byte typeId() { return TAG_COMPOUND; }
// ========== 快捷访问方法 ==========
/**
* 获取指定键的标签
*
* @param key 键名
* @return 标签对象,不存在则返回null
*/
public Tag get(String key) { return children.get(key); }
/**
* 检查是否包含指定键
*
* @param key 键名
* @return 如果包含则返回true
*/
public boolean contains(String key) { return children.containsKey(key); }
/**
* 检查是否包含指定键且类型匹配
*
* @param key 键名
* @param typeId 期望的类型ID
* @return 如果包含且类型匹配则返回true
*/
public boolean contains(String key, byte typeId) {
Tag t = children.get(key);
return t != null && t.typeId() == typeId;
}
/**
* 获取Byte值
*
* @param key 键名
* @return Byte值,不存在或类型不匹配则返回0
*/
public byte getByte(String key) {
Tag t = children.get(key);
return t instanceof Tag.Byte b ? b.value() : 0;
}
/**
* 获取Short值
*
* @param key 键名
* @return Short值,不存在或类型不匹配则返回0
*/
public short getShort(String key) {
Tag t = children.get(key);
return t instanceof Tag.Short s ? s.value() : 0;
}
/**
* 获取Int值
*
* @param key 键名
* @return Int值,不存在或类型不匹配则返回0
*/
public int getInt(String key) {
Tag t = children.get(key);
return t instanceof Tag.Int i ? i.value() : 0;
}
/**
* 获取Long值
*
* @param key 键名
* @return Long值,不存在或类型不匹配则返回0
*/
public long getLong(String key) {
Tag t = children.get(key);
return t instanceof Tag.Long l ? l.value() : 0;
}
/**
* 获取String值
*
* @param key 键名
* @return String值,不存在或类型不匹配则返回空字符串
*/
public String getString(String key) {
Tag t = children.get(key);
return t instanceof StringTag s ? s.value() : "";
}
/**
* 获取Compound子标签
*
* @param key 键名
* @return Compound对象,不存在或类型不匹配则返回空Compound
*/
public Compound getCompound(String key) {
Tag t = children.get(key);
return t instanceof Compound c ? c : new Compound(key, Map.of());
}
/**
* 获取List子标签
*
* @param key 键名
* @param expectedType 期望的元素类型ID
* @return ListTag对象,不存在或类型不匹配则返回空ListTag
*/
public ListTag getList(String key, byte expectedType) {
Tag t = children.get(key);
return t instanceof ListTag l ? l : new ListTag(key, expectedType, List.of());
}
/**
* 获取ByteArray值
*
* @param key 键名
* @return 字节数组,不存在或类型不匹配则返回空数组
*/
public byte[] getByteArray(String key) {
Tag t = children.get(key);
return t instanceof ByteArray ba ? ba.value() : new byte[0];
}
/**
* 获取IntArray值
*
* @param key 键名
* @return 整数数组,不存在或类型不匹配则返回空数组
*/
public int[] getIntArray(String key) {
Tag t = children.get(key);
return t instanceof Tag.IntArray ia ? ia.value() : new int[0];
}
/**
* 获取LongArray值
*
* @param key 键名
* @return 长整数数组,不存在或类型不匹配则返回空数组
*/
public long[] getLongArray(String key) {
Tag t = children.get(key);
return t instanceof LongArray la ? la.value() : new long[0];
}
}
/**
* TAG_Int_Array - 整数数组
*
* @param name 标签名称
* @param value 整数数组
*/
record IntArray(String name, int[] value) implements Tag {
@Override public byte typeId() { return TAG_INT_ARRAY; }
}
/**
* TAG_Long_Array - 长整数数组
*
* @param name 标签名称
* @param value 长整数数组
*/
record LongArray(String name, long[] value) implements Tag {
@Override public byte typeId() { return TAG_LONG_ARRAY; }
}
}
@@ -0,0 +1,113 @@
package com.mapsyncer.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.CRC32;
/**
* 哈希计算工具类
*
* 统一的CRC32哈希计算方法,合并 ClientHashManager 和 GenerationCache 中的重复实现
*/
public final class HashUtils {
private static final Logger LOGGER = LoggerFactory.getLogger(HashUtils.class);
/** 文件不存在或读取失败时返回的默认哈希值 */
public static final String DEFAULT_HASH = "00000000";
private HashUtils() {
// 工具类不允许实例化
}
/**
* 计算文件的CRC32哈希值(流式读取,避免内存峰值)
*
* <p>使用8KB固定缓冲区逐块读取文件,避免Files.readAllBytes导致的内存峰值。</p>
*
* @param filePath 文件路径
* @return CRC32哈希值(8位十六进制字符串),文件不存在或读取失败返回 "00000000"
*/
public static String computeFileHash(Path filePath) {
if (filePath == null || !Files.exists(filePath)) {
return DEFAULT_HASH;
}
CRC32 crc32 = new CRC32();
byte[] buffer = new byte[8192]; // 8KB 固定缓冲区
try (InputStream is = Files.newInputStream(filePath)) {
int len;
while ((len = is.read(buffer)) != -1) {
crc32.update(buffer, 0, len);
}
return String.format("%08x", crc32.getValue());
} catch (IOException e) {
LOGGER.warn("Failed to compute hash for {}", filePath, e);
return DEFAULT_HASH;
}
}
/**
* 计算字节数组的CRC32哈希值
*
* @param data 字节数组
* @return CRC32哈希值(8位十六进制字符串)
*/
public static String computeHash(byte[] data) {
if (data == null || data.length == 0) {
return DEFAULT_HASH;
}
CRC32 crc32 = new CRC32();
crc32.update(data);
return String.format("%08x", crc32.getValue());
}
/**
* 检查哈希值是否有效(非默认值)
*
* @param hash 哈希值
* @return true 如果哈希值有效
*/
public static boolean isValidHash(String hash) {
return hash != null && !hash.isEmpty() && !DEFAULT_HASH.equals(hash);
}
/**
* 校验 region zip 字节是否为完整 ZIP(含 central directory)。
* 用于拒绝分片残留或损坏文件,避免 Xaero 加载崩溃。
*/
public static boolean isValidRegionZip(byte[] data) {
if (data == null || data.length < 22) {
return false;
}
if (data[0] != 0x50 || data[1] != 0x4B || data[2] != 0x03 || data[3] != 0x04) {
return false;
}
for (int i = data.length - 22; i >= 0; i--) {
if (data[i] == 0x50 && data[i + 1] == 0x4B && data[i + 2] == 0x05 && data[i + 3] == 0x06) {
return true;
}
}
return false;
}
/** 校验磁盘上的 region zip 是否完整。 */
public static boolean isValidRegionZip(Path filePath) {
if (filePath == null || !Files.exists(filePath)) {
return false;
}
try {
return isValidRegionZip(Files.readAllBytes(filePath));
} catch (IOException e) {
LOGGER.warn("Failed to read zip for validation: {}", filePath, e);
return false;
}
}
}
@@ -0,0 +1,54 @@
package com.mapsyncer.util;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 线程命名工厂
*
* <p>为线程池创建命名线程,使用原子计数器保证线程名称唯一且稳定。</p>
*
* <p>替代 {@code r -> new Thread(r, "name-" + r.hashCode())} 的不稳定命名方式。</p>
*/
public final class NamedThreadFactory implements java.util.concurrent.ThreadFactory {
private final AtomicInteger counter = new AtomicInteger(0);
private final String baseName;
private final boolean daemon;
/**
* 创建命名线程工厂
*
* @param baseName 线程名称前缀(如 "mapsyncer-converter"
*/
public NamedThreadFactory(String baseName) {
this(baseName, false);
}
/**
* 创建命名线程工厂
*
* @param baseName 线程名称前缀
* @param daemon 是否为守护线程
*/
public NamedThreadFactory(String baseName, boolean daemon) {
this.baseName = baseName;
this.daemon = daemon;
}
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r, baseName + "-" + counter.incrementAndGet());
thread.setDaemon(daemon);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
}
/**
* 获取已创建的线程数量
*
* @return 已创建线程数量
*/
public int getCreatedCount() {
return counter.get();
}
}
@@ -0,0 +1,135 @@
package com.mapsyncer.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.function.Function;
/**
* Properties 格式缓存文件读写工具类
*
* 统一的缓存文件IO操作,合并 ClientTimestampCache 和 GenerationCache 中的重复实现
* 支持泛型值类型,通过解析器/格式化器进行转换
*/
public final class PropertiesCacheIO {
private static final Logger LOGGER = LoggerFactory.getLogger(PropertiesCacheIO.class);
/**
* 私有构造方法,防止实例化
*/
private PropertiesCacheIO() {
// 工具类不允许实例化
}
/**
* 从 Properties 文件加载缓存
*
* @param cacheFile 缓存文件路径
* @param parser 值解析器(字符串 → T)
* @return 加载的缓存数据 Map
*/
public static <T> Map<String, T> load(Path cacheFile, Function<String, T> parser) {
Map<String, T> cache = new HashMap<>();
if (cacheFile == null || !Files.exists(cacheFile)) {
LOGGER.info("Cache file not found: {}", cacheFile);
return cache;
}
try (InputStream is = Files.newInputStream(cacheFile)) {
Properties props = new Properties();
props.load(is);
for (String key : props.stringPropertyNames()) {
T value = parser.apply(props.getProperty(key));
if (value != null) {
cache.put(key, value);
} else {
LOGGER.warn("Invalid cache entry for {}: {}", key, props.getProperty(key));
}
}
LOGGER.info("Loaded {} entries from cache file: {}", cache.size(), cacheFile.getFileName());
} catch (IOException e) {
LOGGER.error("Failed to load cache file: {}", cacheFile, e);
}
return cache;
}
/**
* 保存缓存到 Properties 文件
*
* @param cacheFile 缓存文件路径
* @param cache 缓存数据 Map
* @param formatter 值格式化器(T → 字符串)
* @param header 文件头注释
*/
public static <T> void save(Path cacheFile, Map<String, T> cache, Function<T, String> formatter, String header) {
if (cacheFile == null) {
LOGGER.warn("Cache file path is null, skip saving");
return;
}
try {
Files.createDirectories(cacheFile.getParent());
Properties props = new Properties();
for (Map.Entry<String, T> entry : cache.entrySet()) {
props.setProperty(entry.getKey(), formatter.apply(entry.getValue()));
}
try (OutputStream os = Files.newOutputStream(cacheFile)) {
props.store(os, header != null ? header : "Cache file");
}
LOGGER.info("Saved {} entries to cache file: {}", cache.size(), cacheFile.getFileName());
} catch (IOException e) {
LOGGER.error("Failed to save cache file: {}", cacheFile, e);
}
}
/**
* 解析 "timestamp_seconds:hash" 格式的缓存值
*
* @param value 缓存值字符串(如 "1234567890:abc12345"
* @return TimestampHashEntry 对象,解析失败返回 null
*/
public static TimestampHashEntry parseTimestampHash(String value) {
if (value == null || value.isEmpty()) {
return null;
}
String[] parts = value.split(":");
if (parts.length == 2) {
try {
long ts = Long.parseLong(parts[0]);
return new TimestampHashEntry(ts, parts[1]);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/**
* 时间戳+哈希缓存条目
*/
public record TimestampHashEntry(long timestampSeconds, String hash) {
/**
* 格式化为缓存字符串
*/
public String format() {
return timestampSeconds + ":" + hash;
}
}
}
@@ -0,0 +1,303 @@
package lv.janhouse.mapsyncer;
import lv.janhouse.mapsyncer.cmd.MapSyncCommand;
import lv.janhouse.mapsyncer.discovery.DiscoveryListener;
import lv.janhouse.mapsyncer.discovery.DiscoverySeeder;
import lv.janhouse.mapsyncer.discovery.DiscoveryStore;
import lv.janhouse.mapsyncer.net.SyncService;
import lv.janhouse.mapsyncer.render.RegionRenderer;
import lv.janhouse.mapsyncer.render.Scope;
import lv.janhouse.mapsyncer.world.TrackedWorld;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.world.WorldLoadEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* Server half of MapSyncer for Xaero's World Map, for Paper.
*
* <p>Three cooperating parts: {@link DiscoveryStore} remembers which chunks each
* player has been sent, {@link RegionRenderer} turns region files into Xaero
* region zips limited to those chunks, and {@link SyncService} streams the zips
* to clients that run the MapSyncer client mod.
*/
public final class MapSyncerPlugin extends JavaPlugin implements Listener {
private final AtomicReference<Settings> settings = new AtomicReference<>();
private final Map<String, TrackedWorld> tracked = new ConcurrentHashMap<>();
private final Set<UUID> modUsers = ConcurrentHashMap.newKeySet();
private final AtomicBoolean cycleRunning = new AtomicBoolean();
private final AtomicReference<DiscoverySeeder> activeSeeder = new AtomicReference<>();
private final List<BukkitTask> tasks = new ArrayList<>();
private DiscoveryStore discovery;
private RegionRenderer renderer;
private SyncService sync;
@Override
public void onEnable() {
saveDefaultConfig();
if (!applyConfiguration()) {
getLogger().severe("Configuration failed to load; MapSyncer is inactive.");
return;
}
Path data = getDataFolder().toPath();
discovery = new DiscoveryStore(data.resolve("discovery"));
renderer = new RegionRenderer(getLogger(), discovery, data.resolve("cache"), settings().renderThreads);
for (World world : Bukkit.getWorlds()) {
track(world);
}
sync = new SyncService(this);
sync.register();
Bukkit.getPluginManager().registerEvents(sync, this);
Bukkit.getPluginManager().registerEvents(new DiscoveryListener(this), this);
Bukkit.getPluginManager().registerEvents(this, this);
MapSyncCommand command = new MapSyncCommand(this);
var registered = getCommand("mapsync");
if (registered != null) {
registered.setExecutor(command);
registered.setTabCompleter(command);
}
scheduleTasks();
getLogger().info("Tracking " + tracked.size() + " world(s), visibility=" + settings().visibility
+ ", cache at " + data.resolve("cache"));
}
@Override
public void onDisable() {
for (BukkitTask t : tasks) {
t.cancel();
}
tasks.clear();
DiscoverySeeder seeder = activeSeeder.getAndSet(null);
if (seeder != null) {
seeder.cancel();
}
if (sync != null) {
sync.unregister();
}
if (renderer != null) {
renderer.shutdown();
}
if (discovery != null) {
discovery.saveAll(msg -> getLogger().warning(msg));
}
}
// ------------------------------------------------------------- accessors
public Settings settings() {
return settings.get();
}
public DiscoveryStore discovery() {
return discovery;
}
public RegionRenderer renderer() {
return renderer;
}
public SyncService sync() {
return sync;
}
public boolean isTracked(String worldName) {
return tracked.containsKey(worldName);
}
public TrackedWorld tracked(String worldName) {
return tracked.get(worldName);
}
public Collection<TrackedWorld> trackedWorlds() {
return tracked.values();
}
public Set<UUID> modUsers() {
return modUsers;
}
public Scope scopeFor(UUID player) {
return settings().visibility == Settings.Visibility.OWN ? Scope.of(player) : Scope.SHARED;
}
/** Scopes the background cycle keeps fresh: everyone's, or one per online mod user. */
public List<Scope> activeScopes() {
if (settings().visibility == Settings.Visibility.SHARED) {
return List.of(Scope.SHARED);
}
List<Scope> scopes = new ArrayList<>();
for (Player p : Bukkit.getOnlinePlayers()) {
if (modUsers.contains(p.getUniqueId())) {
scopes.add(Scope.of(p.getUniqueId()));
}
}
return scopes;
}
/** A client just announced the mod. */
public void onModDetected(Player player) {
if (modUsers.add(player.getUniqueId()) && settings().visibility == Settings.Visibility.OWN) {
// Their private view has probably never been rendered; do it now so the
// first poll has something to fetch.
runCycleAsync(List.of(Scope.of(player.getUniqueId())), false);
}
}
// --------------------------------------------------------- configuration
/** Re-reads config.yml. Returns false if it is unusable. */
public boolean applyConfiguration() {
try {
reloadConfig();
settings.set(Settings.load(getConfig(), warning -> getLogger().warning(warning)));
return true;
} catch (RuntimeException e) {
getLogger().severe("Could not load configuration: " + e);
return false;
}
}
/** Re-evaluates which worlds are tracked after a config reload. */
public void retrackWorlds() {
tracked.clear();
for (World world : Bukkit.getWorlds()) {
track(world);
}
}
private void track(World world) {
Settings.WorldSettings ws = settings().world(world.getName());
if (!ws.enabled()) {
getLogger().info("World '" + world.getName() + "' is disabled in config.");
return;
}
TrackedWorld tw = TrackedWorld.of(world, ws.layers(), msg -> getLogger().warning(msg));
if (tw == null) {
return;
}
tracked.put(world.getName(), tw);
discovery.load(world.getName(), msg -> getLogger().warning(msg));
getLogger().info("Tracking " + tw);
if (!discovery.hasFile(world.getName()) && settings().seedFromInhabitedTime) {
seedAsync(tw, null);
}
}
@EventHandler
public void onWorldLoad(WorldLoadEvent event) {
if (discovery != null && !tracked.containsKey(event.getWorld().getName())) {
track(event.getWorld());
}
}
@EventHandler
public void onWorldUnload(WorldUnloadEvent event) {
tracked.remove(event.getWorld().getName());
}
// ------------------------------------------------------------- scheduling
private void scheduleTasks() {
Settings s = settings();
tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously(this,
() -> discovery.saveDirty(msg -> getLogger().warning(msg)),
20L * s.discoverySaveIntervalSeconds, 20L * s.discoverySaveIntervalSeconds));
tasks.add(Bukkit.getScheduler().runTaskTimer(this, () -> sync.sweepPartialRequests(), 100L, 100L));
// 30 s in, so the first cycle never competes with world load on a cold start.
tasks.add(Bukkit.getScheduler().runTaskTimer(this, this::renderCycleTick,
20L * 30, 20L * s.renderIntervalSeconds));
}
/** Main thread: flush worlds if configured, then render in the background. */
private void renderCycleTick() {
List<Scope> scopes = activeScopes();
if (scopes.isEmpty()) {
return;
}
runCycleAsync(scopes, settings().saveWorldBeforeRender);
}
/**
* Starts a render cycle unless one is running. Call from the main thread
* when {@code saveFirst} is set (the world save must happen there).
*/
public boolean runCycleAsync(List<Scope> scopes, boolean saveFirst) {
if (!cycleRunning.compareAndSet(false, true)) {
return false;
}
if (saveFirst) {
for (TrackedWorld tw : tracked.values()) {
if (discovery.hasData(tw.name())) {
tw.world().save();
}
}
}
List<TrackedWorld> worlds = new ArrayList<>(tracked.values());
Bukkit.getScheduler().runTaskAsynchronously(this, () -> {
try {
renderer.renderCycle(worlds, scopes, msg -> getLogger().info(msg));
} finally {
cycleRunning.set(false);
}
});
return true;
}
public boolean isCycleRunning() {
return cycleRunning.get();
}
/** Runs the InhabitedTime seeder for one world in the background. */
public boolean seedAsync(TrackedWorld world, java.util.function.Consumer<String> onDone) {
DiscoverySeeder seeder = new DiscoverySeeder(discovery);
if (!activeSeeder.compareAndSet(null, seeder)) {
return false;
}
long minTicks = settings().seedMinInhabitedTicks;
getLogger().info("Seeding discovery for '" + world.name() + "' from InhabitedTime >= " + minTicks
+ " (one-off scan of " + world.regionDir() + ")...");
Bukkit.getScheduler().runTaskAsynchronously(this, () -> {
try {
DiscoverySeeder.Result r = seeder.seed(world, minTicks, msg -> getLogger().info(msg));
// Write the file even when nothing qualified, so the scan is not repeated at every start.
discovery.markDirty(world.name());
discovery.saveDirty(msg -> getLogger().warning(msg));
String summary = "Seeded '" + world.name() + "': " + r.chunksSeeded() + " of " + r.chunksSeen()
+ " chunks in " + r.files() + " region files" + (r.cancelled() ? " (cancelled)" : "");
getLogger().info(summary);
if (onDone != null) {
onDone.accept(summary);
}
} finally {
activeSeeder.compareAndSet(seeder, null);
}
});
return true;
}
public boolean isSeeding() {
return activeSeeder.get() != null;
}
}
@@ -0,0 +1,113 @@
package lv.janhouse.mapsyncer;
import lv.janhouse.mapsyncer.world.TrackedWorld;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.FileConfiguration;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
/** The parsed contents of {@code config.yml}. Unparseable values fall back to defaults with a warning. */
public final class Settings {
public enum Visibility { SHARED, OWN }
public record WorldSettings(boolean enabled, TrackedWorld.Layers layers) {}
public final Visibility visibility;
public final boolean seedFromInhabitedTime;
public final long seedMinInhabitedTicks;
public final int discoveryRadiusChunks;
public final int discoverySaveIntervalSeconds;
public final int renderIntervalSeconds;
public final int renderThreads;
public final boolean saveWorldBeforeRender;
public final int inlineBudgetSeconds;
public final int maxPacketBytes;
public final int speedLimitKbps;
public final int clientPollIntervalTicks;
public final int helloDelayTicks;
public final String serverName;
public final WorldSettings defaultWorld;
public final Map<String, WorldSettings> worlds;
private Settings(FileConfiguration c, Consumer<String> warn) {
Visibility vis;
String v = c.getString("visibility", "shared");
try {
vis = Visibility.valueOf(v.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
warn.accept("visibility '" + v + "' is not shared|own; using shared");
vis = Visibility.SHARED;
}
visibility = vis;
seedFromInhabitedTime = c.getBoolean("seed.from-inhabited-time", true);
seedMinInhabitedTicks = Math.max(0, c.getLong("seed.min-inhabited-ticks", 1));
discoveryRadiusChunks = c.getInt("discovery.radius-chunks", -1);
discoverySaveIntervalSeconds = clamp(c.getInt("discovery.save-interval-seconds", 60), 5, 3600, "discovery.save-interval-seconds", warn);
renderIntervalSeconds = clamp(c.getInt("render.interval-seconds", 300), 30, 86400, "render.interval-seconds", warn);
renderThreads = clamp(c.getInt("render.threads", 2), 1, 16, "render.threads", warn);
saveWorldBeforeRender = c.getBoolean("render.save-world-before-render", true);
inlineBudgetSeconds = clamp(c.getInt("render.inline-budget-seconds", 420), 0, 540, "render.inline-budget-seconds", warn);
maxPacketBytes = clamp(c.getInt("sync.max-packet-bytes", 262144), 65536, 1_000_000, "sync.max-packet-bytes", warn);
speedLimitKbps = Math.max(0, c.getInt("sync.speed-limit-kbps", 1024));
clientPollIntervalTicks = clamp(c.getInt("sync.client-poll-interval-ticks", 6000), 1200, 72000, "sync.client-poll-interval-ticks", warn);
helloDelayTicks = clamp(c.getInt("sync.hello-delay-ticks", 100), 1, 1200, "sync.hello-delay-ticks", warn);
serverName = c.getString("server-name", "");
ConfigurationSection ws = c.getConfigurationSection("worlds");
WorldSettings def = new WorldSettings(true, TrackedWorld.Layers.AUTO);
Map<String, WorldSettings> map = new HashMap<>();
if (ws != null) {
for (String name : ws.getKeys(false)) {
ConfigurationSection s = ws.getConfigurationSection(name);
if (s == null) {
continue;
}
TrackedWorld.Layers layers = TrackedWorld.parseLayers(s.getString("layers", "auto"), null);
if (layers == null) {
warn.accept("worlds." + name + ".layers '" + s.getString("layers") + "' is not auto|surface|caves|both; using auto");
layers = TrackedWorld.Layers.AUTO;
}
WorldSettings parsed = new WorldSettings(s.getBoolean("enabled", true), layers);
if (name.equals("default")) {
def = parsed;
} else {
map.put(name, parsed);
}
}
}
defaultWorld = def;
worlds = Map.copyOf(map);
}
public static Settings load(FileConfiguration config, Consumer<String> warn) {
return new Settings(config, warn);
}
public WorldSettings world(String name) {
return worlds.getOrDefault(name, defaultWorld);
}
private static int clamp(int value, int min, int max, String key, Consumer<String> warn) {
if (value < min || value > max) {
int fixed = Math.max(min, Math.min(max, value));
warn.accept(key + "=" + value + " is outside " + min + ".." + max + "; using " + fixed);
return fixed;
}
return value;
}
}
@@ -0,0 +1,237 @@
package lv.janhouse.mapsyncer.cmd;
import lv.janhouse.mapsyncer.MapSyncerPlugin;
import lv.janhouse.mapsyncer.Settings;
import lv.janhouse.mapsyncer.render.RegionCache;
import lv.janhouse.mapsyncer.render.Scope;
import lv.janhouse.mapsyncer.world.TrackedWorld;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
/** {@code /mapsync}: inspect and nudge the plugin. */
public final class MapSyncCommand implements TabExecutor {
private static final List<String> SUBCOMMANDS = List.of("status", "worlds", "render", "seed", "reload", "player");
private final MapSyncerPlugin plugin;
public MapSyncCommand(MapSyncerPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label,
String @NotNull [] args) {
String sub = args.length == 0 ? "status" : args[0].toLowerCase(Locale.ROOT);
switch (sub) {
case "status" -> status(sender);
case "worlds" -> worlds(sender);
case "render" -> render(sender, args);
case "seed" -> seed(sender, args);
case "reload" -> reload(sender);
case "player" -> player(sender, args);
default -> sender.sendMessage("Usage: /" + label + " <status|worlds|render [force]|seed <world>|reload|player <name>>");
}
return true;
}
private void status(CommandSender sender) {
Settings s = plugin.settings();
sender.sendMessage("MapSyncer " + plugin.getPluginMeta().getVersion()
+ " — visibility=" + s.visibility.name().toLowerCase(Locale.ROOT)
+ ", " + plugin.trackedWorlds().size() + " tracked world(s)");
long chunks = 0;
int regions = 0;
for (TrackedWorld w : plugin.trackedWorlds()) {
chunks += plugin.discovery().chunkCount(w.name());
regions += plugin.discovery().regionCount(w.name());
}
sender.sendMessage("Discovered: " + chunks + " chunk(s) in " + regions + " region(s)"
+ (plugin.isSeeding() ? " (seeding in progress)" : ""));
long bytes = 0;
int files = 0;
for (RegionCache c : plugin.renderer().loadedCaches()) {
bytes += c.totalBytes();
files += c.size();
}
sender.sendMessage("Cache: " + files + " layer file(s), " + (bytes / 1024) + " KiB, "
+ plugin.renderer().loadedCaches().size() + " scope(s); rendered "
+ plugin.renderer().renderedTotal() + " region(s) since start, "
+ plugin.renderer().inFlightCount() + " in flight"
+ (plugin.isCycleRunning() ? ", cycle running" : ""));
long last = plugin.renderer().lastCycleAt();
sender.sendMessage("Last cycle: " + (last == 0 ? "never" : Instant.ofEpochSecond(last) + " ("
+ plugin.renderer().lastCycleCount() + " refreshed)"));
List<String> online = new ArrayList<>();
for (Player p : Bukkit.getOnlinePlayers()) {
if (plugin.sync().hasMod(p)) {
online.add(p.getName());
}
}
sender.sendMessage("Clients with the mod online: " + (online.isEmpty() ? "none" : String.join(", ", online))
+ "; syncs completed: " + plugin.sync().syncsCompleted()
+ ", active: " + plugin.sync().activeSyncs()
+ ", hellos sent: " + plugin.sync().hellosSent());
}
private void worlds(CommandSender sender) {
if (plugin.trackedWorlds().isEmpty()) {
sender.sendMessage("No worlds are tracked.");
return;
}
for (TrackedWorld w : plugin.trackedWorlds()) {
RegionCache shared = plugin.renderer().cache(Scope.SHARED);
String prefix = w.xaeroDim() + "/";
sender.sendMessage(w.name() + " -> " + w.xaeroDim() + ": " + w.passes().size() + " layer(s), "
+ plugin.discovery().chunkCount(w.name()) + " chunk(s) discovered in "
+ plugin.discovery().regionCount(w.name()) + " region(s), "
+ shared.entries(k -> k.startsWith(prefix)).size() + " shared layer file(s); "
+ w.regionDir());
}
}
private void render(CommandSender sender, String[] args) {
boolean force = args.length > 1 && args[1].equalsIgnoreCase("force");
if (force) {
plugin.renderer().invalidateAll();
sender.sendMessage("Cache cleared; everything will be re-rendered.");
}
List<Scope> scopes = plugin.activeScopes();
if (scopes.isEmpty()) {
sender.sendMessage("Nothing to render for: visibility is 'own' and no client with the mod is online.");
return;
}
if (plugin.runCycleAsync(scopes, plugin.settings().saveWorldBeforeRender)) {
sender.sendMessage("Render cycle started for " + scopes.size() + " scope(s). Watch the console or /mapsync status.");
} else {
sender.sendMessage("A render cycle is already running.");
}
}
private void seed(CommandSender sender, String[] args) {
if (args.length < 2) {
sender.sendMessage("Usage: /mapsync seed <world|all>");
return;
}
if (plugin.isSeeding()) {
sender.sendMessage("A seed run is already in progress.");
return;
}
List<TrackedWorld> targets = new ArrayList<>();
if (args[1].equalsIgnoreCase("all")) {
targets.addAll(plugin.trackedWorlds());
} else {
TrackedWorld w = plugin.tracked(args[1]);
if (w == null) {
sender.sendMessage("World '" + args[1] + "' is not tracked. See /mapsync worlds.");
return;
}
targets.add(w);
}
seedNext(sender, targets, 0);
}
private void seedNext(CommandSender sender, List<TrackedWorld> targets, int index) {
if (index >= targets.size()) {
sender.sendMessage("Seeding finished.");
return;
}
TrackedWorld w = targets.get(index);
boolean started = plugin.seedAsync(w, summary -> Bukkit.getScheduler().runTask(plugin, () -> {
sender.sendMessage(summary);
seedNext(sender, targets, index + 1);
}));
if (started) {
sender.sendMessage("Seeding '" + w.name() + "' in the background...");
} else {
sender.sendMessage("A seed run is already in progress.");
}
}
private void reload(CommandSender sender) {
if (!plugin.applyConfiguration()) {
sender.sendMessage("Reload failed; see console. Previous settings stay active.");
return;
}
plugin.retrackWorlds();
sender.sendMessage("Reloaded config.yml; tracking " + plugin.trackedWorlds().size()
+ " world(s). Thread count and timers take effect after a restart.");
}
private void player(CommandSender sender, String[] args) {
if (args.length < 2) {
sender.sendMessage("Usage: /mapsync player <name>");
return;
}
OfflinePlayer target = Bukkit.getOfflinePlayerIfCached(args[1]);
if (target == null) {
sender.sendMessage("Unknown player '" + args[1] + "'.");
return;
}
UUID id = target.getUniqueId();
Player online = target.getPlayer();
sender.sendMessage(target.getName() + ": " + (online == null ? "offline"
: (plugin.sync().hasMod(online) ? "online, mod present" : "online, no mod channel registered"))
+ ", scope=" + plugin.scopeFor(id).id());
for (TrackedWorld w : plugin.trackedWorlds()) {
sender.sendMessage(" " + w.name() + ": " + plugin.discovery().playerChunkCount(w.name(), id)
+ " chunk(s) discovered by them");
}
}
@Override
public List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias,
String @NotNull [] args) {
if (args.length == 1) {
return filter(SUBCOMMANDS, args[0]);
}
if (args.length == 2) {
switch (args[0].toLowerCase(Locale.ROOT)) {
case "seed" -> {
List<String> names = new ArrayList<>();
names.add("all");
for (TrackedWorld w : plugin.trackedWorlds()) {
names.add(w.name());
}
return filter(names, args[1]);
}
case "render" -> {
return filter(List.of("force"), args[1]);
}
case "player" -> {
List<String> names = new ArrayList<>();
for (Player p : Bukkit.getOnlinePlayers()) {
names.add(p.getName());
}
return filter(names, args[1]);
}
default -> {
return List.of();
}
}
}
return List.of();
}
private static List<String> filter(List<String> options, String prefix) {
String p = prefix.toLowerCase(Locale.ROOT);
List<String> out = new ArrayList<>();
for (String o : options) {
if (o.toLowerCase(Locale.ROOT).startsWith(p)) {
out.add(o);
}
}
return out;
}
}
@@ -0,0 +1,46 @@
package lv.janhouse.mapsyncer.discovery;
import io.papermc.paper.event.packet.PlayerChunkLoadEvent;
import lv.janhouse.mapsyncer.MapSyncerPlugin;
import org.bukkit.Chunk;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
/**
* Turns "the server sent this chunk to this player" into a discovery mark.
*
* <p>That event is the closest server-side equivalent of what Xaero's client
* maps: the client only ever draws chunks it received. Recording exactly that
* set keeps the server's picture of a region a superset of the player's own,
* which matters because the client overwrites region files wholesale.
*/
public final class DiscoveryListener implements Listener {
private final MapSyncerPlugin plugin;
public DiscoveryListener(MapSyncerPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onChunkSent(PlayerChunkLoadEvent event) {
Chunk chunk = event.getChunk();
String world = chunk.getWorld().getName();
if (!plugin.isTracked(world)) {
return;
}
Player player = event.getPlayer();
int radius = plugin.settings().discoveryRadiusChunks;
if (radius >= 0) {
int px = player.getLocation().getBlockX() >> 4;
int pz = player.getLocation().getBlockZ() >> 4;
if (Math.max(Math.abs(px - chunk.getX()), Math.abs(pz - chunk.getZ())) > radius) {
return;
}
}
plugin.discovery().mark(world, player.getUniqueId(), chunk.getX(), chunk.getZ(),
System.currentTimeMillis() / 1000);
}
}
@@ -0,0 +1,106 @@
package lv.janhouse.mapsyncer.discovery;
import com.mapsyncer.mca.McaReader;
import com.mapsyncer.nbt.Tag;
import lv.janhouse.mapsyncer.world.TrackedWorld;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Reconstructs discovery history from the world itself.
*
* <p>Vanilla increments a chunk's {@code InhabitedTime} only while a player is
* near it, so it separates terrain people have actually been to from terrain
* that was merely generated (pre-generation, distant-terrain LOD, a portal
* search). Chunks over the threshold are marked as seeded — discovered, but by
* nobody in particular.
*
* <p>Reads every region file once; run it off the main thread.
*/
public final class DiscoverySeeder {
private static final Pattern MCA_NAME = Pattern.compile("r\\.(-?\\d+)\\.(-?\\d+)\\.mca");
private final DiscoveryStore store;
private final AtomicBoolean cancelled = new AtomicBoolean();
public DiscoverySeeder(DiscoveryStore store) {
this.store = store;
}
public void cancel() {
cancelled.set(true);
}
public record Result(int files, long chunksSeen, long chunksSeeded, boolean cancelled) {}
public Result seed(TrackedWorld world, long minInhabitedTicks, Consumer<String> log) {
List<Path> files = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(world.regionDir(), "r.*.mca")) {
for (Path p : stream) {
files.add(p);
}
} catch (IOException e) {
log.accept("Cannot list " + world.regionDir() + ": " + e.getMessage());
return new Result(0, 0, 0, false);
}
long now = System.currentTimeMillis() / 1000;
long seen = 0;
long seeded = 0;
int done = 0;
for (Path file : files) {
if (cancelled.get()) {
return new Result(done, seen, seeded, true);
}
Matcher m = MCA_NAME.matcher(file.getFileName().toString());
if (!m.matches()) {
continue;
}
int rx = Integer.parseInt(m.group(1));
int rz = Integer.parseInt(m.group(2));
try (McaReader reader = McaReader.open(file.toString())) {
for (int lx = 0; lx < 32; lx++) {
for (int lz = 0; lz < 32; lz++) {
Tag.Compound nbt;
try {
nbt = reader.readChunkNbt(lx, lz);
} catch (IOException | RuntimeException e) {
continue;
}
if (nbt == null) {
continue;
}
seen++;
String status = nbt.getString("Status");
if (status == null || status.isEmpty() || status.endsWith("empty")) {
continue;
}
if (nbt.getLong("InhabitedTime") < minInhabitedTicks) {
continue;
}
if (store.markSeeded(world.name(), (rx << 5) + lx, (rz << 5) + lz, now)) {
seeded++;
}
}
}
} catch (IOException | RuntimeException e) {
log.accept("Skipping " + file.getFileName() + ": " + e.getMessage());
}
done++;
if (done % 50 == 0) {
log.accept("Seeding '" + world.name() + "': " + done + "/" + files.size() + " region files, "
+ seeded + " chunks so far");
}
}
return new Result(done, seen, seeded, false);
}
}
@@ -0,0 +1,259 @@
package lv.janhouse.mapsyncer.discovery;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
/**
* Discovery bitmaps for every tracked world, persisted under
* {@code <data>/discovery/<world>.bin}.
*
* <p>Marks arrive on the main thread (chunk packets), renders and syncs read
* from worker threads; the per-region objects synchronise themselves and the
* maps are concurrent, so no global lock is needed. Worlds are keyed by Bukkit
* name so a renamed dimension key does not orphan the file.
*/
public final class DiscoveryStore {
private static final int MAGIC = 0x4D534443; // "MSDC"
private static final int VERSION = 1;
private final Path dir;
private final Map<String, ConcurrentHashMap<Long, RegionDiscovery>> worlds = new ConcurrentHashMap<>();
private final Set<String> dirtyWorlds = ConcurrentHashMap.newKeySet();
/** Regions whose bitmap changed since the renderer last looked, per world. */
private final Map<String, Set<Long>> dirtyRegions = new ConcurrentHashMap<>();
public DiscoveryStore(Path dir) {
this.dir = dir;
}
public static long regionKey(int regionX, int regionZ) {
return ((long) regionX << 32) | (regionZ & 0xFFFFFFFFL);
}
public static int regionX(long key) {
return (int) (key >> 32);
}
public static int regionZ(long key) {
return (int) key;
}
// ---------------------------------------------------------------- marks
/** Records that {@code player} received chunk (chunkX, chunkZ). Returns true if it was new for them. */
public boolean mark(String world, UUID player, int chunkX, int chunkZ, long nowSeconds) {
int rx = chunkX >> 5;
int rz = chunkZ >> 5;
RegionDiscovery region = region(world, rx, rz, true);
boolean changed = region.mark(player, RegionDiscovery.index(chunkX & 31, chunkZ & 31), nowSeconds);
if (changed) {
touch(world, regionKey(rx, rz));
}
return changed;
}
public boolean markSeeded(String world, int chunkX, int chunkZ, long nowSeconds) {
int rx = chunkX >> 5;
int rz = chunkZ >> 5;
RegionDiscovery region = region(world, rx, rz, true);
boolean changed = region.markSeeded(RegionDiscovery.index(chunkX & 31, chunkZ & 31), nowSeconds);
if (changed) {
touch(world, regionKey(rx, rz));
}
return changed;
}
/** Forces the next {@link #saveDirty} to write this world even if nothing changed. */
public void markDirty(String world) {
worlds.computeIfAbsent(world, w -> new ConcurrentHashMap<>());
dirtyWorlds.add(world);
}
private void touch(String world, long key) {
dirtyWorlds.add(world);
dirtyRegions.computeIfAbsent(world, w -> ConcurrentHashMap.newKeySet()).add(key);
}
public RegionDiscovery region(String world, int regionX, int regionZ, boolean create) {
ConcurrentHashMap<Long, RegionDiscovery> map = worlds.computeIfAbsent(world, w -> new ConcurrentHashMap<>());
long key = regionKey(regionX, regionZ);
return create ? map.computeIfAbsent(key, k -> new RegionDiscovery()) : map.get(key);
}
/** All regions with any discovery in a world, as a stable copy. */
public Map<Long, RegionDiscovery> regions(String world) {
ConcurrentHashMap<Long, RegionDiscovery> map = worlds.get(world);
return map == null ? Map.of() : new HashMap<>(map);
}
public boolean hasData(String world) {
ConcurrentHashMap<Long, RegionDiscovery> map = worlds.get(world);
return map != null && !map.isEmpty();
}
/** Drains the set of regions changed since the last call. */
public Set<Long> drainDirtyRegions(String world) {
Set<Long> set = dirtyRegions.remove(world);
return set == null ? Set.of() : set;
}
public int regionCount(String world) {
ConcurrentHashMap<Long, RegionDiscovery> map = worlds.get(world);
return map == null ? 0 : map.size();
}
public long chunkCount(String world) {
ConcurrentHashMap<Long, RegionDiscovery> map = worlds.get(world);
if (map == null) {
return 0;
}
long n = 0;
for (RegionDiscovery d : map.values()) {
n += d.unionCount();
}
return n;
}
public long playerChunkCount(String world, UUID player) {
ConcurrentHashMap<Long, RegionDiscovery> map = worlds.get(world);
if (map == null) {
return 0;
}
long n = 0;
for (RegionDiscovery d : map.values()) {
n += d.playerCount(player);
}
return n;
}
// ---------------------------------------------------------- persistence
private Path fileFor(String world) {
return dir.resolve(world + ".bin");
}
public boolean hasFile(String world) {
return Files.exists(fileFor(world));
}
/** Loads one world's file if present. */
public void load(String world, Consumer<String> warn) {
Path file = fileFor(world);
if (!Files.exists(file)) {
return;
}
ConcurrentHashMap<Long, RegionDiscovery> map = new ConcurrentHashMap<>();
try (DataInputStream in = new DataInputStream(new BufferedInputStream(Files.newInputStream(file), 1 << 16))) {
if (in.readInt() != MAGIC) {
throw new IOException("bad magic");
}
int version = in.readInt();
if (version != VERSION) {
throw new IOException("unsupported version " + version);
}
int regions = in.readInt();
for (int i = 0; i < regions; i++) {
int rx = in.readInt();
int rz = in.readInt();
long unionStamp = in.readLong();
byte[] union = readBits(in);
long seededStamp = in.readLong();
byte[] seeded = readBits(in);
int players = in.readInt();
Map<UUID, byte[]> byPlayer = new HashMap<>();
Map<UUID, Long> stamps = new HashMap<>();
for (int p = 0; p < players; p++) {
UUID id = new UUID(in.readLong(), in.readLong());
long stamp = in.readLong();
byPlayer.put(id, readBits(in));
stamps.put(id, stamp);
}
map.put(regionKey(rx, rz), RegionDiscovery.fromSnapshot(
new RegionDiscovery.Snapshot(union, unionStamp, seeded, seededStamp, byPlayer, stamps)));
}
worlds.put(world, map);
} catch (IOException e) {
warn.accept("Could not read discovery data for '" + world + "' (" + file + "): " + e.getMessage()
+ " — starting empty; the file is left in place.");
}
}
private static byte[] readBits(DataInputStream in) throws IOException {
byte[] bits = new byte[RegionDiscovery.CHUNKS / 8];
in.readFully(bits);
return bits;
}
/** Writes every world changed since the last save. Safe to call from any thread. */
public List<String> saveDirty(Consumer<String> warn) {
List<String> saved = new ArrayList<>();
for (String world : new ArrayList<>(dirtyWorlds)) {
dirtyWorlds.remove(world);
try {
save(world);
saved.add(world);
} catch (IOException e) {
dirtyWorlds.add(world);
warn.accept("Could not save discovery data for '" + world + "': " + e.getMessage());
}
}
return saved;
}
public void saveAll(Consumer<String> warn) {
dirtyWorlds.addAll(worlds.keySet());
saveDirty(warn);
}
private void save(String world) throws IOException {
ConcurrentHashMap<Long, RegionDiscovery> map = worlds.get(world);
if (map == null) {
return;
}
Files.createDirectories(dir);
Path file = fileFor(world);
Path tmp = dir.resolve(world + ".bin.tmp");
List<Map.Entry<Long, RegionDiscovery.Snapshot>> entries = new ArrayList<>(map.size());
for (Map.Entry<Long, RegionDiscovery> e : map.entrySet()) {
RegionDiscovery.Snapshot s = e.getValue().snapshot();
entries.add(Map.entry(e.getKey(), s));
}
try (DataOutputStream out = new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(tmp), 1 << 16))) {
out.writeInt(MAGIC);
out.writeInt(VERSION);
out.writeInt(entries.size());
for (Map.Entry<Long, RegionDiscovery.Snapshot> e : entries) {
RegionDiscovery.Snapshot s = e.getValue();
out.writeInt(regionX(e.getKey()));
out.writeInt(regionZ(e.getKey()));
out.writeLong(s.unionStamp());
out.write(s.union());
out.writeLong(s.seededStamp());
out.write(s.seeded());
out.writeInt(s.players().size());
for (Map.Entry<UUID, byte[]> p : s.players().entrySet()) {
out.writeLong(p.getKey().getMostSignificantBits());
out.writeLong(p.getKey().getLeastSignificantBits());
out.writeLong(s.playerStamps().getOrDefault(p.getKey(), 0L));
out.write(p.getValue());
}
}
}
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
}
}
@@ -0,0 +1,161 @@
package lv.janhouse.mapsyncer.discovery;
import com.mapsyncer.mca.ChunkMask;
import java.util.BitSet;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Which of a region's 32×32 chunks have been discovered, by whom, and when
* that last changed.
*
* <p>Three bit sets: {@code seeded} (history reconstructed from
* {@code InhabitedTime}, not attributable to anyone), one per player, and the
* {@code union} of all of them. Stamps are epoch seconds and only move when a
* bit is actually added; the renderer compares them against what it last
* rendered, so a chunk re-sent to a player who already had it costs nothing.
*
* <p>All access is synchronised on the instance; masks are snapshots.
*/
public final class RegionDiscovery {
public static final int CHUNKS = 32 * 32;
private final BitSet union = new BitSet(CHUNKS);
private final BitSet seeded = new BitSet(CHUNKS);
private final Map<UUID, BitSet> byPlayer = new HashMap<>();
private final Map<UUID, Long> playerStamp = new HashMap<>();
private long unionStamp;
private long seededStamp;
public static int index(int localX, int localZ) {
return (localX & 31) << 5 | (localZ & 31);
}
/** @return true if the chunk was new for this player */
public synchronized boolean mark(UUID player, int index, long nowSeconds) {
BitSet bits = byPlayer.computeIfAbsent(player, k -> new BitSet(CHUNKS));
if (bits.get(index)) {
return false;
}
bits.set(index);
playerStamp.put(player, nowSeconds);
if (!union.get(index)) {
union.set(index);
unionStamp = nowSeconds;
}
return true;
}
/** @return true if the chunk was not known at all before */
public synchronized boolean markSeeded(int index, long nowSeconds) {
if (seeded.get(index)) {
return false;
}
seeded.set(index);
seededStamp = nowSeconds;
if (!union.get(index)) {
union.set(index);
unionStamp = nowSeconds;
}
return true;
}
public synchronized boolean isEmpty() {
return union.isEmpty();
}
public synchronized int unionCount() {
return union.cardinality();
}
public synchronized int seededCount() {
return seeded.cardinality();
}
public synchronized int playerCount(UUID player) {
BitSet bits = byPlayer.get(player);
return bits == null ? 0 : bits.cardinality();
}
public synchronized boolean hasPlayerData(UUID player) {
BitSet bits = byPlayer.get(player);
return bits != null && !bits.isEmpty();
}
/** Everything anyone has discovered. */
public synchronized ChunkMask unionMask() {
return of((BitSet) union.clone());
}
/** What one player discovered, plus the unattributable seeded history. */
public synchronized ChunkMask playerMask(UUID player) {
BitSet bits = (BitSet) seeded.clone();
BitSet own = byPlayer.get(player);
if (own != null) {
bits.or(own);
}
return of(bits);
}
public synchronized boolean playerMaskIsEmpty(UUID player) {
BitSet own = byPlayer.get(player);
return seeded.isEmpty() && (own == null || own.isEmpty());
}
public synchronized long unionStamp() {
return unionStamp;
}
public synchronized long playerStamp(UUID player) {
Long s = playerStamp.get(player);
return Math.max(seededStamp, s == null ? 0 : s);
}
private static ChunkMask of(BitSet bits) {
return (x, z) -> bits.get(index(x, z));
}
// ---------------------------------------------------------- persistence
/** Snapshot for serialisation; see {@link DiscoveryStore}. */
public synchronized Snapshot snapshot() {
Map<UUID, byte[]> players = new HashMap<>();
Map<UUID, Long> stamps = new HashMap<>();
for (Map.Entry<UUID, BitSet> e : byPlayer.entrySet()) {
if (!e.getValue().isEmpty()) {
players.put(e.getKey(), toBytes(e.getValue()));
stamps.put(e.getKey(), playerStamp.getOrDefault(e.getKey(), 0L));
}
}
return new Snapshot(toBytes(union), unionStamp, toBytes(seeded), seededStamp, players, stamps);
}
static RegionDiscovery fromSnapshot(Snapshot s) {
RegionDiscovery d = new RegionDiscovery();
d.union.or(BitSet.valueOf(s.union()));
d.unionStamp = s.unionStamp();
d.seeded.or(BitSet.valueOf(s.seeded()));
d.seededStamp = s.seededStamp();
for (Map.Entry<UUID, byte[]> e : s.players().entrySet()) {
d.byPlayer.put(e.getKey(), BitSet.valueOf(e.getValue()));
d.playerStamp.put(e.getKey(), s.playerStamps().getOrDefault(e.getKey(), 0L));
}
return d;
}
private static byte[] toBytes(BitSet bits) {
byte[] raw = bits.toByteArray();
if (raw.length == CHUNKS / 8) {
return raw;
}
byte[] fixed = new byte[CHUNKS / 8];
System.arraycopy(raw, 0, fixed, 0, Math.min(raw.length, fixed.length));
return fixed;
}
public record Snapshot(byte[] union, long unionStamp, byte[] seeded, long seededStamp,
Map<UUID, byte[]> players, Map<UUID, Long> playerStamps) {}
}
@@ -0,0 +1,91 @@
package lv.janhouse.mapsyncer.net;
import java.nio.charset.StandardCharsets;
/**
* Counterpart of {@link PacketWriter}: reads the vanilla {@code FriendlyByteBuf}
* primitives the MapSyncer client writes into a serverbound payload.
*
* <p>Every read is bounds-checked and throws {@link MalformedPacketException},
* so a truncated or hostile payload never turns into an
* {@code ArrayIndexOutOfBoundsException} on the network thread.
*/
public final class PacketReader {
/** Vanilla caps strings at 32767 UTF-16 units; the mod's keys are far shorter. */
private static final int MAX_STRING_BYTES = 32767 * 3;
private final byte[] buf;
private int pos;
public PacketReader(byte[] buf) {
this.buf = buf;
}
public int readableBytes() {
return buf.length - pos;
}
public int readByte() {
need(1);
return buf[pos++] & 0xFF;
}
public boolean readBoolean() {
return readByte() != 0;
}
public int readInt() {
need(4);
int v = ((buf[pos] & 0xFF) << 24) | ((buf[pos + 1] & 0xFF) << 16)
| ((buf[pos + 2] & 0xFF) << 8) | (buf[pos + 3] & 0xFF);
pos += 4;
return v;
}
public long readLong() {
long hi = readInt() & 0xFFFFFFFFL;
long lo = readInt() & 0xFFFFFFFFL;
return (hi << 32) | lo;
}
public int readVarInt() {
int value = 0;
int shift = 0;
while (true) {
int b = readByte();
value |= (b & 0x7F) << shift;
if ((b & 0x80) == 0) {
return value;
}
shift += 7;
if (shift > 35) {
throw new MalformedPacketException("VarInt too long");
}
}
}
public String readUtf() {
int len = readVarInt();
if (len < 0 || len > MAX_STRING_BYTES) {
throw new MalformedPacketException("String length out of range: " + len);
}
need(len);
String s = new String(buf, pos, len, StandardCharsets.UTF_8);
pos += len;
return s;
}
private void need(int n) {
if (n < 0 || pos + n > buf.length) {
throw new MalformedPacketException("Truncated payload: need " + n + " bytes at " + pos + " of " + buf.length);
}
}
/** Thrown for any structurally invalid payload. */
public static final class MalformedPacketException extends RuntimeException {
public MalformedPacketException(String message) {
super(message);
}
}
}
@@ -0,0 +1,86 @@
package lv.janhouse.mapsyncer.net;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
/**
* Minimal writer producing the same bytes as Minecraft's {@code FriendlyByteBuf}
* for the handful of primitives the MapSyncer protocol uses.
*
* <p>Everything is big-endian. {@link #writeUtf} and {@link #writeByteArray}
* are VarInt-length-prefixed, exactly like vanilla; counts written by the mod
* itself are plain 4-byte ints (see {@link Protocol}).
*/
public final class PacketWriter {
private final ByteArrayOutputStream out;
public PacketWriter() {
this(256);
}
public PacketWriter(int expectedSize) {
this.out = new ByteArrayOutputStream(expectedSize);
}
public PacketWriter writeByte(int value) {
out.write(value & 0xFF);
return this;
}
public PacketWriter writeBoolean(boolean value) {
out.write(value ? 1 : 0);
return this;
}
public PacketWriter writeInt(int value) {
out.write(value >>> 24);
out.write(value >>> 16);
out.write(value >>> 8);
out.write(value);
return this;
}
public PacketWriter writeLong(long value) {
writeInt((int) (value >>> 32));
writeInt((int) value);
return this;
}
public PacketWriter writeVarInt(int value) {
while ((value & ~0x7F) != 0) {
out.write((value & 0x7F) | 0x80);
value >>>= 7;
}
out.write(value);
return this;
}
/** VarInt byte length followed by UTF-8 bytes (vanilla {@code writeUtf}). */
public PacketWriter writeUtf(String value) {
byte[] bytes = (value == null ? "" : value).getBytes(StandardCharsets.UTF_8);
writeVarInt(bytes.length);
out.write(bytes, 0, bytes.length);
return this;
}
/** VarInt length followed by the raw bytes (vanilla {@code writeByteArray}). */
public PacketWriter writeByteArray(byte[] value) {
writeVarInt(value.length);
out.write(value, 0, value.length);
return this;
}
public PacketWriter writeBytes(byte[] value, int offset, int length) {
out.write(value, offset, length);
return this;
}
public int size() {
return out.size();
}
public byte[] toByteArray() {
return out.toByteArray();
}
}
@@ -0,0 +1,200 @@
package lv.janhouse.mapsyncer.net;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* The MapSyncer wire protocol, byte-for-byte as the stock client mod speaks it.
*
* <p>Four play-phase plugin channels. The server talks first with
* {@link #SERVER_INSTALLED}; the only serverbound message is
* {@link #SYNC_REQUEST}; the server answers with a stream of
* {@link #SYNC_RESPONSE} packets, each followed by a {@link #SYNC_PROGRESS},
* and terminates with exactly one response whose {@code complete} flag is set.
* There is no version negotiation: framing is the whole compatibility contract,
* so the encoders below are deliberately literal transcriptions of the mod's
* codecs rather than anything clever.
*/
public final class Protocol {
public static final String SYNC_REQUEST = "mapsyncer:sync_request";
public static final String SYNC_RESPONSE = "mapsyncer:sync_response";
public static final String SYNC_PROGRESS = "mapsyncer:sync_progress";
public static final String SERVER_INSTALLED = "mapsyncer:server_installed";
/** Largest slice of a region zip in one {@link ChunkPart}. */
public static final int MAX_PART_BYTES = 28_000;
/** Hard cap on the sum of part bytes in one response packet (vanilla payload limit is 1 MiB). */
public static final int MAX_PACKET_DATA_BYTES = 1_000_000;
/** Surface layer marker, matching the mod's {@code ChunkMapData.caveLayer} convention. */
public static final int SURFACE_LAYER = Integer.MAX_VALUE;
/** Hash value the client uses for "no valid file, please send". */
public static final String NO_HASH = "00000000";
/** Key suffix the client sends on a first targeted sync instead of real entries. */
public static final String PLACEHOLDER_SUFFIX = "/_placeholder_";
public static final String STATUS_OK = "ok";
public static final String STATUS_PARTIAL = "partial";
public static final String STATUS_UPTODATE = "uptodate";
public static final String STATUS_NO_CACHE = "no_cache";
public static final String STATUS_DIM_NOT_AVAILABLE = "dim_not_available";
/** Client update modes; the ordinal is what goes on the wire. */
public enum UpdateMode { DISABLED, TICK, SCHEDULED }
private Protocol() {}
// --------------------------------------------------------------- records
/** What the client knows about one region: the server timestamp it stored and the CRC32 of its file. */
public record ClientMeta(long timestampSeconds, String hash) {}
/** One (possibly partial) serverbound request. */
public record SyncRequest(Map<String, ClientMeta> clientMeta, boolean split, int partIndex, int totalParts,
boolean syncAll, String targetDimension, boolean silent) {}
/**
* One region blob or a slice of it.
*
* @param dimension the Xaero folder name the client should write into, verbatim
* @param caveLayer {@link #SURFACE_LAYER} for the surface map
* @param partIndex 0-based; {@code totalParts} 0 or 1 means unsplit
*/
public record ChunkPart(int regionX, int regionZ, String dimension, byte[] data, long timestampSeconds,
int caveLayer, int partIndex, int totalParts) {
public boolean isSplit() {
return totalParts > 1;
}
}
// -------------------------------------------------------------- encoders
public static byte[] encodeServerInstalled(String version, long lastGenerationTimestamp,
int autoSyncIntervalMinutes, UpdateMode mode,
int incrementalUpdateIntervalTicks, String serverName) {
return new PacketWriter(64)
.writeUtf(version)
.writeLong(lastGenerationTimestamp)
.writeInt(autoSyncIntervalMinutes)
.writeByte(mode.ordinal())
.writeInt(incrementalUpdateIntervalTicks)
.writeUtf(serverName == null ? "" : serverName)
.toByteArray();
}
public static byte[] encodeSyncProgress(int processed, int total, String status) {
return new PacketWriter(32)
.writeInt(processed)
.writeInt(total)
.writeUtf(status)
.toByteArray();
}
public static byte[] encodeSyncResponse(int worldId, List<ChunkPart> parts, boolean complete, String status) {
int expected = 32;
for (ChunkPart p : parts) {
expected += p.data().length + 48;
}
PacketWriter w = new PacketWriter(expected);
w.writeInt(worldId);
w.writeInt(parts.size());
for (ChunkPart p : parts) {
w.writeInt(p.regionX());
w.writeInt(p.regionZ());
w.writeUtf(p.dimension());
w.writeByteArray(p.data());
w.writeLong(p.timestampSeconds());
boolean hasCave = p.caveLayer() != SURFACE_LAYER;
// Both trailing booleans are always written: the client's reader only
// tolerates their absence for the LAST chunk of a packet.
w.writeBoolean(hasCave);
if (hasCave) {
w.writeInt(p.caveLayer());
}
w.writeBoolean(p.isSplit());
if (p.isSplit()) {
w.writeInt(p.partIndex());
w.writeInt(p.totalParts());
}
}
w.writeBoolean(complete);
w.writeUtf(status);
return w.toByteArray();
}
/** Splits a region zip into parts of at most {@link #MAX_PART_BYTES}. */
public static List<ChunkPart> split(int regionX, int regionZ, String dimension, byte[] zip,
long timestampSeconds, int caveLayer) {
int total = Math.max(1, (zip.length + MAX_PART_BYTES - 1) / MAX_PART_BYTES);
List<ChunkPart> parts = new ArrayList<>(total);
if (total == 1) {
parts.add(new ChunkPart(regionX, regionZ, dimension, zip, timestampSeconds, caveLayer, 0, 0));
return parts;
}
for (int i = 0; i < total; i++) {
int from = i * MAX_PART_BYTES;
int to = Math.min(zip.length, from + MAX_PART_BYTES);
byte[] slice = new byte[to - from];
System.arraycopy(zip, from, slice, 0, slice.length);
parts.add(new ChunkPart(regionX, regionZ, dimension, slice, timestampSeconds, caveLayer, i, total));
}
return parts;
}
// --------------------------------------------------------------- decoder
/**
* Decodes a serverbound sync request. Mirrors the mod's reader, including its
* tolerance for a payload that stops right after the meta map.
*/
public static SyncRequest decodeSyncRequest(byte[] payload) {
PacketReader r = new PacketReader(payload);
int count = r.readInt();
if (count < 0 || count > 100_000) {
throw new PacketReader.MalformedPacketException("Unreasonable meta count: " + count);
}
Map<String, ClientMeta> meta = new LinkedHashMap<>(Math.min(count, 4096) * 2);
for (int i = 0; i < count; i++) {
String key = r.readUtf();
long ts = r.readLong();
String hash = r.readUtf();
meta.put(key, new ClientMeta(ts, hash));
}
boolean split = false;
int partIndex = 0;
int totalParts = 0;
boolean syncAll = false;
String target = "";
boolean silent = false;
if (r.readableBytes() > 0) {
split = r.readBoolean();
if (split) {
partIndex = r.readInt();
totalParts = r.readInt();
}
}
if (r.readableBytes() > 0) {
syncAll = r.readBoolean();
if (!syncAll && r.readableBytes() > 0) {
target = r.readUtf();
}
} else if (meta.isEmpty()) {
// Legacy payload with nothing after an empty map: treat as "sync all".
syncAll = true;
}
if (r.readableBytes() > 0) {
silent = r.readBoolean();
}
return new SyncRequest(meta, split, partIndex, totalParts, syncAll, target, silent);
}
/** {@code true} for the mod's "resend me this" hash values. */
public static boolean isValidHash(String hash) {
return hash != null && hash.length() == 8 && !NO_HASH.equals(hash);
}
}
@@ -0,0 +1,564 @@
package lv.janhouse.mapsyncer.net;
import lv.janhouse.mapsyncer.MapSyncerPlugin;
import lv.janhouse.mapsyncer.Settings;
import lv.janhouse.mapsyncer.discovery.DiscoveryStore;
import lv.janhouse.mapsyncer.discovery.RegionDiscovery;
import lv.janhouse.mapsyncer.render.RegionCache;
import lv.janhouse.mapsyncer.render.RegionRenderer;
import lv.janhouse.mapsyncer.render.Scope;
import lv.janhouse.mapsyncer.world.TrackedWorld;
import lv.janhouse.mapsyncer.world.XaeroDimension;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRegisterChannelEvent;
import org.bukkit.plugin.messaging.Messenger;
import org.bukkit.plugin.messaging.PluginMessageListener;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* The network half: hello on join, request reassembly, and the per-player
* streaming thread that answers a sync request from the region cache.
*
* <p>Every outbound packet is handed to the main thread and guarded by the
* session version, so a superseded or disconnected sync can never leak packets
* into a newer one — the client tolerates a truncated stream but not a stray
* completion.
*/
public final class SyncService implements PluginMessageListener, Listener {
private static final long REQUEST_PART_TIMEOUT_MS = 20_000;
private static final TextColor PREFIX_COLOR = TextColor.color(0xFFE55E);
private final MapSyncerPlugin plugin;
private final Logger log;
private final AtomicInteger versions = new AtomicInteger();
private final Map<UUID, PlayerState> players = new ConcurrentHashMap<>();
private final AtomicInteger syncsCompleted = new AtomicInteger();
private final AtomicInteger helloCount = new AtomicInteger();
private static final class PlayerState {
final Map<Integer, Protocol.SyncRequest> parts = new HashMap<>();
int expectedParts;
long lastPartAt;
volatile int version;
volatile Thread thread;
BukkitTask helloTask;
boolean helloSent;
long windowStart;
long windowBytes;
}
public SyncService(MapSyncerPlugin plugin) {
this.plugin = plugin;
this.log = plugin.getLogger();
}
public void register() {
Messenger m = Bukkit.getMessenger();
m.registerIncomingPluginChannel(plugin, Protocol.SYNC_REQUEST, this);
m.registerOutgoingPluginChannel(plugin, Protocol.SYNC_RESPONSE);
m.registerOutgoingPluginChannel(plugin, Protocol.SYNC_PROGRESS);
m.registerOutgoingPluginChannel(plugin, Protocol.SERVER_INSTALLED);
}
public void unregister() {
Messenger m = Bukkit.getMessenger();
m.unregisterIncomingPluginChannel(plugin);
m.unregisterOutgoingPluginChannel(plugin);
for (PlayerState s : players.values()) {
cancel(s);
}
players.clear();
}
public int activeSyncs() {
int n = 0;
for (PlayerState s : players.values()) {
if (s.thread != null && s.thread.isAlive()) {
n++;
}
}
return n;
}
public int syncsCompleted() {
return syncsCompleted.get();
}
public int hellosSent() {
return helloCount.get();
}
public boolean hasMod(Player player) {
return player.getListeningPluginChannels().contains(Protocol.SERVER_INSTALLED);
}
private PlayerState state(Player p) {
return players.computeIfAbsent(p.getUniqueId(), k -> new PlayerState());
}
// ------------------------------------------------------------- lifecycle
@EventHandler
public void onRegisterChannel(PlayerRegisterChannelEvent event) {
if (!Protocol.SERVER_INSTALLED.equals(event.getChannel())) {
return;
}
scheduleHello(event.getPlayer());
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
// Belt and braces: if the channel registration arrived before we were
// listening (plugin reload), the listening set still knows about it.
Player p = event.getPlayer();
Bukkit.getScheduler().runTaskLater(plugin, () -> {
if (p.isOnline() && hasMod(p)) {
scheduleHello(p);
}
}, plugin.settings().helloDelayTicks);
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
PlayerState s = players.remove(event.getPlayer().getUniqueId());
if (s != null) {
cancel(s);
}
}
private void cancel(PlayerState s) {
s.version = versions.incrementAndGet();
if (s.helloTask != null) {
s.helloTask.cancel();
s.helloTask = null;
}
Thread t = s.thread;
if (t != null) {
t.interrupt();
}
synchronized (s) {
s.parts.clear();
s.expectedParts = 0;
}
}
private void scheduleHello(Player player) {
PlayerState s = state(player);
if (s.helloSent || s.helloTask != null) {
return;
}
// The client needs a few seconds after join to read its own cache; a
// hello that arrives earlier makes it believe it has nothing and re-sync.
s.helloTask = Bukkit.getScheduler().runTaskLater(plugin, () -> {
s.helloTask = null;
if (player.isOnline()) {
sendHello(player);
}
}, plugin.settings().helloDelayTicks);
}
/** Sends the server hello. Main thread. */
public void sendHello(Player player) {
PlayerState s = state(player);
Settings settings = plugin.settings();
Scope scope = plugin.scopeFor(player.getUniqueId());
RegionCache cache = plugin.renderer().cache(scope);
long lastGen = cache.maxTimestamp(k -> true);
int ticks = settings.clientPollIntervalTicks;
int minutes = Math.max(1, ticks / 20 / 60);
byte[] payload = Protocol.encodeServerInstalled(
"mapsyncer-paper/" + plugin.getPluginMeta().getVersion(),
lastGen, minutes, Protocol.UpdateMode.TICK, ticks, settings.serverName);
player.sendPluginMessage(plugin, Protocol.SERVER_INSTALLED, payload);
s.helloSent = true;
helloCount.incrementAndGet();
plugin.onModDetected(player);
}
// -------------------------------------------------------------- inbound
@Override
public void onPluginMessageReceived(@NotNull String channel, @NotNull Player player, byte @NotNull [] message) {
if (!Protocol.SYNC_REQUEST.equals(channel)) {
return;
}
Protocol.SyncRequest request;
try {
request = Protocol.decodeSyncRequest(message);
} catch (PacketReader.MalformedPacketException e) {
log.warning("Ignoring malformed sync request from " + player.getName() + ": " + e.getMessage());
return;
}
PlayerState s = state(player);
Protocol.SyncRequest merged;
synchronized (s) {
if (!request.split()) {
s.parts.clear();
s.expectedParts = 0;
merged = request;
} else {
if (s.expectedParts != request.totalParts()) {
s.parts.clear();
s.expectedParts = request.totalParts();
}
s.parts.put(request.partIndex(), request);
s.lastPartAt = System.currentTimeMillis();
if (s.parts.size() < s.expectedParts) {
return;
}
Map<String, Protocol.ClientMeta> all = new LinkedHashMap<>();
for (Protocol.SyncRequest part : s.parts.values()) {
all.putAll(part.clientMeta());
}
merged = new Protocol.SyncRequest(all, false, 0, 0, request.syncAll(), request.targetDimension(),
request.silent());
s.parts.clear();
s.expectedParts = 0;
}
}
startSync(player, s, merged);
}
/** Called every few seconds from the scheduler: drops half-received requests. */
public void sweepPartialRequests() {
long now = System.currentTimeMillis();
for (Map.Entry<UUID, PlayerState> e : players.entrySet()) {
PlayerState s = e.getValue();
boolean timedOut;
synchronized (s) {
timedOut = s.expectedParts > 0 && now - s.lastPartAt > REQUEST_PART_TIMEOUT_MS;
if (timedOut) {
s.parts.clear();
s.expectedParts = 0;
}
}
if (timedOut) {
Player p = Bukkit.getPlayer(e.getKey());
if (p != null) {
p.sendPluginMessage(plugin, Protocol.SYNC_PROGRESS,
Protocol.encodeSyncProgress(0, 0, "request_partial_timeout"));
}
}
}
}
// ------------------------------------------------------------- the sync
private record Candidate(TrackedWorld world, int rx, int rz, RegionDiscovery discovery, long stamp, int distance) {}
private record Item(TrackedWorld world, int rx, int rz, String key, int caveLayer, RegionCache.Entry entry, int distance) {}
private void startSync(Player player, PlayerState s, Protocol.SyncRequest request) {
Thread old = s.thread;
int version = versions.incrementAndGet();
s.version = version;
if (old != null && old.isAlive()) {
old.interrupt();
}
Location loc = player.getLocation();
String startWorld = loc.getWorld().getName();
int prx = loc.getBlockX() >> 9;
int prz = loc.getBlockZ() >> 9;
UUID id = player.getUniqueId();
String name = player.getName();
Thread t = new Thread(() -> {
try {
runSync(player, id, name, s, version, request, startWorld, prx, prz);
} catch (InterruptedException ignored) {
// superseded or player left
} catch (RuntimeException e) {
log.log(Level.WARNING, "Sync for " + name + " failed", e);
} finally {
if (s.thread == Thread.currentThread()) {
s.thread = null;
}
}
}, "mapsyncer-sync-" + name);
t.setDaemon(true);
s.thread = t;
t.start();
}
private void runSync(Player player, UUID id, String name, PlayerState s, int version,
Protocol.SyncRequest request, String startWorld, int prx, int prz)
throws InterruptedException {
Settings settings = plugin.settings();
Scope scope = plugin.scopeFor(id);
RegionRenderer renderer = plugin.renderer();
RegionCache cache = renderer.cache(scope);
DiscoveryStore discovery = plugin.discovery();
boolean silent = request.silent();
// Which worlds the client asked for.
List<TrackedWorld> worlds = new ArrayList<>();
if (request.syncAll()) {
worlds.addAll(plugin.trackedWorlds());
} else {
Set<String> wanted = new HashSet<>();
if (!request.targetDimension().isEmpty()) {
wanted.add(request.targetDimension());
} else {
for (String key : request.clientMeta().keySet()) {
wanted.add(XaeroDimension.prefixOf(key));
}
}
for (TrackedWorld w : plugin.trackedWorlds()) {
for (String dim : wanted) {
if (XaeroDimension.matches(dim, w.world())) {
worlds.add(w);
break;
}
}
}
}
if (worlds.isEmpty()) {
chat(player, s, version, Component.translatable("mapsyncer.server.dim_not_available",
"Dimension '%s' map data not available.", Component.text(request.targetDimension()),
Component.text("mapsync"), Component.text(request.targetDimension())), NamedTextColor.RED);
send(player, s, version, Protocol.SYNC_RESPONSE,
Protocol.encodeSyncResponse(0, List.of(), true, Protocol.STATUS_DIM_NOT_AVAILABLE));
return;
}
// Tell the client we are alive before any rendering happens.
send(player, s, version, Protocol.SYNC_PROGRESS, Protocol.encodeSyncProgress(0, 0, "Preparing regions..."));
// Candidate regions: everything discovered in scope, nearest first.
List<Candidate> candidates = new ArrayList<>();
for (TrackedWorld w : worlds) {
boolean sameWorld = w.name().equals(startWorld);
for (Map.Entry<Long, RegionDiscovery> e : discovery.regions(w.name()).entrySet()) {
RegionDiscovery d = e.getValue();
if (scope.maskIsEmpty(d)) {
continue;
}
int rx = DiscoveryStore.regionX(e.getKey());
int rz = DiscoveryStore.regionZ(e.getKey());
long stamp = renderer.contentStamp(w, rx, rz, d, scope);
if (stamp == 0) {
continue;
}
int distance = sameWorld ? Math.max(Math.abs(rx - prx), Math.abs(rz - prz)) : 1_000_000;
candidates.add(new Candidate(w, rx, rz, d, stamp, distance));
}
}
candidates.sort(Comparator.comparingInt(Candidate::distance));
// Render what is stale, within the inline budget; leftovers wait for the cycle.
long deadline = System.currentTimeMillis() + settings.inlineBudgetSeconds * 1000L;
int rendered = 0;
int deferred = 0;
List<CompletableFuture<Boolean>> pending = new ArrayList<>();
for (Candidate c : candidates) {
checkAlive(s, version);
if (renderer.isFresh(scope, c.world(), c.rx(), c.rz(), c.stamp())) {
continue;
}
if (System.currentTimeMillis() > deadline) {
deferred++;
continue;
}
pending.add(renderer.ensureRendered(scope, c.world(), c.rx(), c.rz(), c.discovery(), c.stamp()));
if (pending.size() >= settings.renderThreads * 2) {
rendered += drain(pending, deadline, s, version);
}
}
rendered += drain(pending, deadline, s, version);
if (rendered > 0) {
cache.saveIfDirty(msg -> log.warning(msg));
}
// Decide, per layer, what the client is missing.
List<Item> items = new ArrayList<>();
int hashMatch = 0;
int tsSkip = 0;
for (Candidate c : candidates) {
for (var pass : c.world().passes()) {
String key = c.world().key(pass.caveLayer(), c.rx(), c.rz());
RegionCache.Entry entry = cache.get(key);
if (entry == null) {
continue;
}
Protocol.ClientMeta meta = request.clientMeta().get(key);
if (meta != null && Protocol.isValidHash(meta.hash())) {
if (entry.crc32().equalsIgnoreCase(meta.hash())) {
hashMatch++;
continue;
}
if (meta.timestampSeconds() >= entry.timestampSeconds()) {
tsSkip++;
continue;
}
}
items.add(new Item(c.world(), c.rx(), c.rz(), key, pass.caveLayer(), entry, c.distance()));
}
}
int total = items.size();
if (total == 0) {
if (!silent) {
chat(player, s, version, Component.translatable("mapsyncer.server.map_uptodate",
"Map already up to date.", Component.text(hashMatch), Component.text(tsSkip)), NamedTextColor.GREEN);
}
send(player, s, version, Protocol.SYNC_RESPONSE,
Protocol.encodeSyncResponse(0, List.of(), true, Protocol.STATUS_UPTODATE));
if (deferred > 0) {
log.info(name + ": up to date for now, " + deferred + " region(s) still rendering in the background.");
}
return;
}
if (!silent) {
chat(player, s, version, Component.translatable("mapsyncer.server.sync_start",
"Downloading %s regions", Component.text(total), Component.text(hashMatch), Component.text(tsSkip)),
PREFIX_COLOR);
}
send(player, s, version, Protocol.SYNC_PROGRESS, Protocol.encodeSyncProgress(0, total, "Sync started"));
// Stream.
int maxPacket = Math.min(settings.maxPacketBytes, Protocol.MAX_PACKET_DATA_BYTES);
List<Protocol.ChunkPart> batch = new ArrayList<>();
int batchBytes = 0;
int processed = 0;
int sent = 0;
int failed = 0;
for (Item item : items) {
checkAlive(s, version);
byte[] zip;
try {
zip = Files.readAllBytes(cache.zipPath(item.key()));
} catch (IOException e) {
failed++;
processed++;
continue;
}
for (Protocol.ChunkPart part : Protocol.split(item.rx(), item.rz(), item.world().xaeroDim(), zip,
item.entry().timestampSeconds(), item.caveLayer())) {
if (!batch.isEmpty() && batchBytes + part.data().length > maxPacket) {
flush(player, s, version, batch, false, Protocol.STATUS_OK, processed, total);
throttle(s, batchBytes, settings.speedLimitKbps);
batch = new ArrayList<>();
batchBytes = 0;
}
batch.add(part);
batchBytes += part.data().length;
}
processed++;
sent++;
}
String status = failed > 0 ? Protocol.STATUS_PARTIAL : Protocol.STATUS_OK;
flush(player, s, version, batch, true, status, total, total);
syncsCompleted.incrementAndGet();
if (!silent) {
if (failed > 0) {
chat(player, s, version, Component.translatable("mapsyncer.server.sync_partial",
"Map sync partially complete: sent %s regions, %s read failures (of %s planned)",
Component.text(sent), Component.text(failed), Component.text(total)), NamedTextColor.RED);
} else {
chat(player, s, version, Component.translatable("mapsyncer.server.sync_complete",
"Map sync complete: sent %s updated regions", Component.text(sent)), NamedTextColor.GREEN);
}
}
log.info(name + ": sent " + sent + " region layer(s) (" + hashMatch + " unchanged, " + tsSkip
+ " newer on client, " + rendered + " rendered inline, " + deferred + " deferred).");
}
private int drain(List<CompletableFuture<Boolean>> pending, long deadline, PlayerState s, int version)
throws InterruptedException {
int done = 0;
for (CompletableFuture<Boolean> f : pending) {
checkAlive(s, version);
long wait = Math.max(1000, deadline - System.currentTimeMillis());
try {
f.get(wait, TimeUnit.MILLISECONDS);
done++;
} catch (TimeoutException e) {
// Leave it running for the background cycle to collect.
} catch (ExecutionException e) {
log.log(Level.WARNING, "Inline render failed: " + e.getCause());
}
}
pending.clear();
return done;
}
private void flush(Player player, PlayerState s, int version, List<Protocol.ChunkPart> batch,
boolean complete, String status, int processed, int total) {
send(player, s, version, Protocol.SYNC_RESPONSE, Protocol.encodeSyncResponse(0, batch, complete, status));
send(player, s, version, Protocol.SYNC_PROGRESS, complete
? Protocol.encodeSyncProgress(total, total, "completed")
: Protocol.encodeSyncProgress(processed, total, "Sending regions " + processed + "/" + total));
}
private static void throttle(PlayerState s, int bytes, int limitKbps) throws InterruptedException {
if (limitKbps <= 0) {
return;
}
long now = System.currentTimeMillis();
if (now - s.windowStart >= 1000) {
s.windowStart = now;
s.windowBytes = 0;
}
s.windowBytes += bytes;
long allowedMs = s.windowBytes * 1000L / (limitKbps * 1024L);
long elapsed = now - s.windowStart;
if (allowedMs > elapsed) {
Thread.sleep(allowedMs - elapsed);
}
}
private static void checkAlive(PlayerState s, int version) throws InterruptedException {
if (Thread.currentThread().isInterrupted() || s.version != version) {
throw new InterruptedException("superseded");
}
}
private void send(Player player, PlayerState s, int version, String channel, byte[] payload) {
Bukkit.getScheduler().runTask(plugin, () -> {
if (s.version == version && player.isOnline()) {
player.sendPluginMessage(plugin, channel, payload);
}
});
}
private void chat(Player player, PlayerState s, int version, Component body, TextColor color) {
Component prefix = Component.translatable("mapsyncer.prefix", "[MapSyncer]").color(PREFIX_COLOR);
Component msg = prefix.append(Component.text(" ")).append(body.color(color));
Bukkit.getScheduler().runTask(plugin, () -> {
if (s.version == version && player.isOnline()) {
player.sendMessage(msg);
}
});
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
package lv.janhouse.mapsyncer.render;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.level.material.Fluids;
/**
* 占位 {@link BlockGetter},用于在无真实世界上下文时调用方块 API(如 getMapColor、getLightBlock)。
*
* <p>必须在 MC 模块内直接实现接口,避免 platform-api 通过 Mojmap 类名反射
* Fabric 运行时类名为 intermediary,反射会失败)。</p>
*/
public final class PlaceholderBlockGetter implements BlockGetter {
public static final PlaceholderBlockGetter INSTANCE = new PlaceholderBlockGetter();
private static final BlockState AIR = Blocks.AIR.defaultBlockState();
private static final FluidState EMPTY = Fluids.EMPTY.defaultFluidState();
private PlaceholderBlockGetter() {}
@Override
public BlockEntity getBlockEntity(BlockPos pos) {
return null;
}
@Override
public BlockState getBlockState(BlockPos pos) {
return AIR;
}
@Override
public FluidState getFluidState(BlockPos pos) {
return EMPTY;
}
// Paper adds "if loaded" variants as abstract methods.
@Override
public BlockState getBlockStateIfLoaded(BlockPos pos) {
return AIR;
}
@Override
public FluidState getFluidIfLoaded(BlockPos pos) {
return EMPTY;
}
@Override
public int getHeight() {
return 256;
}
@Override
public int getMinY() {
return -64;
}
}
@@ -0,0 +1,206 @@
package lv.janhouse.mapsyncer.render;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Predicate;
/**
* Rendered region zips for one {@link Scope}, plus the index the sync logic
* needs without touching the files: per-layer timestamp and CRC32 (what the
* client compares against) and the content stamp each region was rendered
* from (what the renderer compares against).
*
* <p>Layout: {@code <root>/<xaeroDim>/[caves/<L>/]<x>_<z>.zip}, i.e. the wire
* key plus {@code .zip}, so a key maps to a file without any translation. The
* index is a tab-separated text file next to it.
*/
public final class RegionCache {
/** One rendered layer of a region. */
public record Entry(long timestampSeconds, String crc32, long contentStamp, long sizeBytes) {}
private static final String INDEX_NAME = "index.tsv";
private final Path root;
private final Map<String, Entry> entries = new ConcurrentHashMap<>();
/** Region key ({@code <dim>/<x>_<z>}) → content stamp of the last complete render, all layers. */
private final Map<String, Long> rendered = new ConcurrentHashMap<>();
private final AtomicBoolean dirty = new AtomicBoolean();
public RegionCache(Path root) {
this.root = root;
}
public Path root() {
return root;
}
public Path zipPath(String key) {
return root.resolve(key + ".zip");
}
public Entry get(String key) {
return entries.get(key);
}
public void put(String key, Entry entry) {
entries.put(key, entry);
dirty.set(true);
}
/** Forgets a layer and deletes its file. */
public void remove(String key) {
if (entries.remove(key) != null) {
dirty.set(true);
}
try {
Files.deleteIfExists(zipPath(key));
} catch (IOException ignored) {
// A stale file only costs disk space; the index no longer references it.
}
}
public long renderedStamp(String regionKey) {
Long s = rendered.get(regionKey);
return s == null ? -1 : s;
}
public void setRenderedStamp(String regionKey, long stamp) {
rendered.put(regionKey, stamp);
dirty.set(true);
}
public void forgetRendered(String regionKey) {
if (rendered.remove(regionKey) != null) {
dirty.set(true);
}
}
/** Forgets everything and deletes the files. */
public void clear() {
for (String key : new ArrayList<>(entries.keySet())) {
remove(key);
}
rendered.clear();
dirty.set(true);
}
/** Snapshot of all entries whose key passes the filter. */
public Map<String, Entry> entries(Predicate<String> keyFilter) {
Map<String, Entry> out = new ConcurrentHashMap<>();
for (Map.Entry<String, Entry> e : entries.entrySet()) {
if (keyFilter.test(e.getKey())) {
out.put(e.getKey(), e.getValue());
}
}
return out;
}
public int size() {
return entries.size();
}
public long totalBytes() {
long n = 0;
for (Entry e : entries.values()) {
n += e.sizeBytes();
}
return n;
}
/** Largest timestamp of any layer whose key passes the filter, 0 if none. */
public long maxTimestamp(Predicate<String> keyFilter) {
long max = 0;
for (Map.Entry<String, Entry> e : entries.entrySet()) {
if (keyFilter.test(e.getKey())) {
max = Math.max(max, e.getValue().timestampSeconds());
}
}
return max;
}
// ---------------------------------------------------------- persistence
public void load(Consumer<String> warn) {
Path index = root.resolve(INDEX_NAME);
if (!Files.exists(index)) {
return;
}
int dropped = 0;
try (BufferedReader in = Files.newBufferedReader(index, StandardCharsets.UTF_8)) {
String line;
while ((line = in.readLine()) != null) {
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
String[] f = line.split("\t");
try {
if (f[0].equals("L") && f.length >= 6) {
String key = f[1];
Entry e = new Entry(Long.parseLong(f[2]), f[3], Long.parseLong(f[4]), Long.parseLong(f[5]));
if (Files.exists(zipPath(key))) {
entries.put(key, e);
} else {
dropped++;
}
} else if (f[0].equals("R") && f.length >= 3) {
rendered.put(f[1], Long.parseLong(f[2]));
}
} catch (RuntimeException e) {
dropped++;
}
}
} catch (IOException e) {
warn.accept("Could not read " + index + ": " + e.getMessage() + " — cache will be rebuilt.");
entries.clear();
rendered.clear();
}
if (dropped > 0) {
warn.accept(dropped + " cache index entries in " + root + " had no file and were dropped.");
dirty.set(true);
}
}
/** Writes the index if anything changed. Safe from any thread; last writer wins. */
public void saveIfDirty(Consumer<String> warn) {
if (!dirty.compareAndSet(true, false)) {
return;
}
try {
Files.createDirectories(root);
Path tmp = root.resolve(INDEX_NAME + ".tmp");
List<String> lines = new ArrayList<>(entries.size() + rendered.size() + 1);
lines.add("# L\tkey\ttimestamp\tcrc32\tcontentStamp\tsize | R\tregionKey\tcontentStamp");
for (Map.Entry<String, Entry> e : entries.entrySet()) {
Entry v = e.getValue();
lines.add("L\t" + e.getKey() + "\t" + v.timestampSeconds() + "\t" + v.crc32() + "\t"
+ v.contentStamp() + "\t" + v.sizeBytes());
}
for (Map.Entry<String, Long> e : rendered.entrySet()) {
lines.add("R\t" + e.getKey() + "\t" + e.getValue());
}
try (BufferedWriter out = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) {
for (String l : lines) {
out.write(l);
out.newLine();
}
}
Files.move(tmp, root.resolve(INDEX_NAME), StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
dirty.set(true);
warn.accept("Could not write cache index under " + root + ": " + e.getMessage());
}
}
}
@@ -0,0 +1,268 @@
package lv.janhouse.mapsyncer.render;
import com.mapsyncer.XaeroWriter;
import com.mapsyncer.mca.ChunkMask;
import com.mapsyncer.mca.RegionConverterStandalone.ConvertedRegion;
import com.mapsyncer.mca.RegionConverterStandalone.LayerConvertedRegion;
import com.mapsyncer.mca.convert.RegionConversionPipeline;
import com.mapsyncer.mca.convert.scan.RegionScanPass;
import lv.janhouse.mapsyncer.discovery.DiscoveryStore;
import lv.janhouse.mapsyncer.discovery.RegionDiscovery;
import lv.janhouse.mapsyncer.world.TrackedWorld;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Turns region files plus discovery masks into Xaero region zips, per scope.
*
* <p>A region is (re)rendered when its <em>content stamp</em> — the newer of
* the MCA file's mtime and the moment the scope's discovery mask last grew —
* is newer than the stamp it was last rendered from. Timestamps handed to
* clients are the render time, made strictly increasing per layer so the
* client's "keep the newer one" rule always resolves in favour of the newer
* content.
*
* <p>Rendering is CPU- and IO-heavy (a full parse of a ~10 MB region file), so
* it runs on a small fixed pool; identical in-flight renders are coalesced.
*/
public final class RegionRenderer {
private final Logger log;
private final DiscoveryStore discovery;
private final Path cacheRoot;
private final ExecutorService pool;
private final Map<String, RegionCache> caches = new ConcurrentHashMap<>();
private final Map<String, CompletableFuture<Boolean>> inFlight = new ConcurrentHashMap<>();
private final AtomicInteger renderedTotal = new AtomicInteger();
private final AtomicLong lastCycleAt = new AtomicLong();
private final AtomicInteger lastCycleCount = new AtomicInteger();
private volatile boolean shuttingDown;
public RegionRenderer(Logger log, DiscoveryStore discovery, Path cacheRoot, int threads) {
this.log = log;
this.discovery = discovery;
this.cacheRoot = cacheRoot;
int n = Math.max(1, threads);
this.pool = Executors.newFixedThreadPool(n, r -> {
Thread t = new Thread(r, "mapsyncer-render");
t.setDaemon(true);
t.setPriority(Thread.MIN_PRIORITY + 1);
return t;
});
}
public RegionCache cache(Scope scope) {
return caches.computeIfAbsent(scope.id(), id -> {
RegionCache c = new RegionCache(cacheRoot.resolve(id));
c.load(msg -> log.warning(msg));
return c;
});
}
public Collection<RegionCache> loadedCaches() {
return caches.values();
}
public int renderedTotal() {
return renderedTotal.get();
}
public long lastCycleAt() {
return lastCycleAt.get();
}
public int lastCycleCount() {
return lastCycleCount.get();
}
public int inFlightCount() {
return inFlight.size();
}
// ------------------------------------------------------------ freshness
public static String regionKey(TrackedWorld world, int rx, int rz) {
return world.xaeroDim() + "/" + rx + "_" + rz;
}
/** The stamp a fresh render of this region would carry. 0 if there is no file. */
public long contentStamp(TrackedWorld world, int rx, int rz, RegionDiscovery d, Scope scope) {
long mtime;
try {
mtime = Files.getLastModifiedTime(world.mcaPath(rx, rz)).toMillis() / 1000;
} catch (IOException e) {
return 0;
}
return Math.max(mtime, scope.stamp(d));
}
public boolean isFresh(Scope scope, TrackedWorld world, int rx, int rz, long contentStamp) {
return cache(scope).renderedStamp(regionKey(world, rx, rz)) >= contentStamp;
}
// -------------------------------------------------------------- render
/**
* Renders the region for the scope unless an identical render is already
* running, in which case that one's future is returned. Completes with
* {@code true} when at least one layer has content.
*/
public CompletableFuture<Boolean> ensureRendered(Scope scope, TrackedWorld world, int rx, int rz,
RegionDiscovery d, long contentStamp) {
if (shuttingDown) {
return CompletableFuture.completedFuture(false);
}
String flightKey = scope.id() + "|" + regionKey(world, rx, rz);
return inFlight.computeIfAbsent(flightKey, k -> {
CompletableFuture<Boolean> f = CompletableFuture.supplyAsync(
() -> render(scope, world, rx, rz, scope.mask(d), contentStamp), pool);
f.whenComplete((r, t) -> inFlight.remove(k));
return f;
});
}
private boolean render(Scope scope, TrackedWorld world, int rx, int rz, ChunkMask mask, long contentStamp) {
RegionCache cache = cache(scope);
String regionKey = regionKey(world, rx, rz);
Path mca = world.mcaPath(rx, rz);
List<RegionScanPass> passes = world.passes();
List<LayerConvertedRegion> layers;
try {
if (!Files.exists(mca)) {
layers = List.of();
} else {
layers = RegionConversionPipeline.convertMulti(
mca, rx, rz, world.typeInfo(), passes, BlockPropertyResolver.INSTANCE, mask);
}
} catch (IOException | RuntimeException e) {
log.log(Level.WARNING, "Render failed for " + regionKey + " (" + scope.id() + "): " + e);
return false;
}
long now = System.currentTimeMillis() / 1000;
boolean any = false;
for (int i = 0; i < passes.size(); i++) {
RegionScanPass pass = passes.get(i);
String key = world.key(pass.caveLayer(), rx, rz);
LayerConvertedRegion layer = i < layers.size() ? layers.get(i) : null;
if (layer == null || layer.xaeroData() == null || layer.xaeroData().length == 0) {
cache.remove(key);
continue;
}
try {
Path outDir = pass.isSurfaceLayer()
? cache.root().resolve(world.xaeroDim())
: cache.root().resolve(world.xaeroDim()).resolve("caves").resolve(String.valueOf(pass.caveLayer()));
XaeroWriter.RegionWriteResult written = XaeroWriter.writeRegionFile(outDir,
new ConvertedRegion(rx, rz, layer.xaeroData()));
RegionCache.Entry previous = cache.get(key);
long ts = now;
if (previous != null) {
if (previous.crc32().equals(written.crc32Hash())) {
// Same bytes as before: keep the old timestamp so clients
// that already have it are not told it changed.
ts = previous.timestampSeconds();
} else {
ts = Math.max(now, previous.timestampSeconds() + 1);
}
}
cache.put(key, new RegionCache.Entry(ts, written.crc32Hash(), contentStamp,
Files.size(written.path())));
any = true;
} catch (IOException e) {
log.log(Level.WARNING, "Could not write " + key + " (" + scope.id() + "): " + e);
return false;
}
}
cache.setRenderedStamp(regionKey, contentStamp);
renderedTotal.incrementAndGet();
return any;
}
// --------------------------------------------------------------- cycle
/**
* Re-renders everything stale for the given scopes. Blocks until done, so
* call it from a worker thread. Returns the number of regions submitted.
*/
public int renderCycle(Collection<TrackedWorld> worlds, Collection<Scope> scopes, Consumer<String> info) {
List<CompletableFuture<Boolean>> futures = new ArrayList<>();
for (TrackedWorld world : worlds) {
discovery.drainDirtyRegions(world.name());
for (Map.Entry<Long, RegionDiscovery> e : discovery.regions(world.name()).entrySet()) {
int rx = DiscoveryStore.regionX(e.getKey());
int rz = DiscoveryStore.regionZ(e.getKey());
RegionDiscovery d = e.getValue();
for (Scope scope : scopes) {
if (scope.maskIsEmpty(d)) {
continue;
}
long stamp = contentStamp(world, rx, rz, d, scope);
if (stamp == 0 || isFresh(scope, world, rx, rz, stamp)) {
continue;
}
futures.add(ensureRendered(scope, world, rx, rz, d, stamp));
}
}
}
for (CompletableFuture<Boolean> f : futures) {
try {
f.join();
} catch (RuntimeException ignored) {
// Logged where it happened.
}
}
for (RegionCache c : caches.values()) {
c.saveIfDirty(msg -> log.warning(msg));
}
lastCycleAt.set(System.currentTimeMillis() / 1000);
lastCycleCount.set(futures.size());
if (!futures.isEmpty()) {
info.accept("Render cycle: " + futures.size() + " region(s) refreshed across " + scopes.size() + " scope(s).");
}
return futures.size();
}
/** Drops every cached render so the next cycle rebuilds from scratch. */
public void invalidateAll() {
for (RegionCache c : caches.values()) {
c.clear();
c.saveIfDirty(msg -> log.warning(msg));
}
}
public void saveIndexes() {
for (RegionCache c : caches.values()) {
c.saveIfDirty(msg -> log.warning(msg));
}
}
public void shutdown() {
shuttingDown = true;
pool.shutdown();
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
saveIndexes();
}
}
@@ -0,0 +1,38 @@
package lv.janhouse.mapsyncer.render;
import lv.janhouse.mapsyncer.discovery.RegionDiscovery;
import com.mapsyncer.mca.ChunkMask;
import java.util.UUID;
/**
* Whose view of the world a rendered region represents: everyone's (the
* {@code shared} visibility) or one player's ({@code own}).
*
* <p>The id doubles as the cache directory name.
*/
public record Scope(String id, UUID player) {
public static final Scope SHARED = new Scope("shared", null);
public static Scope of(UUID player) {
return new Scope(player.toString(), player);
}
public boolean isShared() {
return player == null;
}
public ChunkMask mask(RegionDiscovery discovery) {
return isShared() ? discovery.unionMask() : discovery.playerMask(player);
}
public boolean maskIsEmpty(RegionDiscovery discovery) {
return isShared() ? discovery.isEmpty() : discovery.playerMaskIsEmpty(player);
}
/** Epoch seconds when this scope's mask of a region last grew. */
public long stamp(RegionDiscovery discovery) {
return isShared() ? discovery.unionStamp() : discovery.playerStamp(player);
}
}
@@ -0,0 +1,183 @@
package lv.janhouse.mapsyncer.world;
import com.mapsyncer.mca.DimensionTypeInfo;
import com.mapsyncer.mca.LightMode;
import com.mapsyncer.mca.RegionConverterStandalone.CaveModeParams;
import com.mapsyncer.mca.convert.scan.RegionScanPass;
import com.mapsyncer.mca.convert.scan.ScanVerticalBounds;
import org.bukkit.Bukkit;
import org.bukkit.World;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.function.Consumer;
/**
* A Bukkit world the plugin renders: where its region files live, how tall it
* is, which Xaero folder it maps to and which layers are produced for it.
*/
public final class TrackedWorld {
/** Which map layers a world gets. */
public enum Layers { AUTO, SURFACE, CAVES, BOTH }
private static final int CAVE_LAYER_DEPTH = 15;
private final World world;
private final String xaeroDim;
private final Path regionDir;
private final DimensionTypeInfo typeInfo;
private final List<RegionScanPass> passes;
private TrackedWorld(World world, String xaeroDim, Path regionDir, DimensionTypeInfo typeInfo,
List<RegionScanPass> passes) {
this.world = world;
this.xaeroDim = xaeroDim;
this.regionDir = regionDir;
this.typeInfo = typeInfo;
this.passes = List.copyOf(passes);
}
/**
* @return null (after a warning) when the world's region directory cannot be found
*/
public static TrackedWorld of(World world, Layers layers, Consumer<String> warn) {
Path regionDir = findRegionDir(world);
if (regionDir == null) {
warn.accept("World '" + world.getName() + "': no region directory found, not tracking it.");
return null;
}
DimensionTypeInfo info = new DimensionTypeInfo(
world.hasSkyLight(),
world.hasCeiling(),
world.getMinHeight(),
world.getMaxHeight() - world.getMinHeight(),
world.getLogicalHeight());
List<RegionScanPass> passes = plan(layers, info);
return new TrackedWorld(world, XaeroDimension.folderFor(world), regionDir, info, passes);
}
// ---------------------------------------------------------------- layout
/**
* Locates the {@code region/} directory. Paper 26.x keeps every dimension,
* the main overworld included, under {@code <main>/dimensions/<ns>/<path>/};
* older layouts are probed after that so a migrated server still works.
*/
static Path findRegionDir(World world) {
List<Path> candidates = new ArrayList<>();
String ns = world.getKey().getNamespace();
String path = world.getKey().getKey();
List<World> worlds = Bukkit.getWorlds();
Path main = worlds.isEmpty() ? null : worlds.get(0).getWorldFolder().toPath();
Path own = world.getWorldFolder().toPath();
if (main != null) {
candidates.add(main.resolve("dimensions").resolve(ns).resolve(path).resolve("region"));
}
candidates.add(own.resolve("dimensions").resolve(ns).resolve(path).resolve("region"));
switch (world.getEnvironment()) {
case NETHER -> candidates.add(own.resolve("DIM-1").resolve("region"));
case THE_END -> candidates.add(own.resolve("DIM1").resolve("region"));
default -> { }
}
candidates.add(own.resolve("region"));
for (Path c : candidates) {
if (Files.isDirectory(c)) {
return c;
}
}
return null;
}
// ---------------------------------------------------------------- layers
/** Same plan the upstream mod builds, minus the roof layers above a ceiling. */
public static List<RegionScanPass> plan(Layers layers, DimensionTypeInfo info) {
boolean surface;
boolean caves;
switch (layers) {
case SURFACE -> { surface = true; caves = false; }
case CAVES -> { surface = false; caves = true; }
case BOTH -> { surface = true; caves = true; }
default -> { surface = !info.hasCeiling(); caves = info.hasCeiling(); }
}
List<RegionScanPass> passes = new ArrayList<>();
if (surface) {
ScanVerticalBounds bounds = info.hasUpperZone()
? ScanVerticalBounds.aboveY(info.logicalTopY(), info.maxY())
: ScanVerticalBounds.fullColumn(info.minY(), info.maxY());
passes.add(new RegionScanPass(Integer.MAX_VALUE, LightMode.SURFACE, CaveModeParams.NONE, bounds));
}
if (caves) {
int minLayer = Math.floorDiv(info.minY(), 16);
int maxLayer = Math.floorDiv(info.logicalTopY(), 16);
for (int layer = minLayer; layer <= maxLayer; layer++) {
int caveStart = (layer << 4) + 15;
passes.add(new RegionScanPass(layer, LightMode.CAVE,
new CaveModeParams(caveStart, CAVE_LAYER_DEPTH), ScanVerticalBounds.unbounded()));
}
}
if (passes.isEmpty()) {
passes.add(new RegionScanPass(Integer.MAX_VALUE, LightMode.SURFACE, CaveModeParams.NONE,
ScanVerticalBounds.fullColumn(info.minY(), info.maxY())));
}
return passes;
}
public static Layers parseLayers(String value, Layers fallback) {
if (value == null) {
return fallback;
}
try {
return Layers.valueOf(value.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
return fallback;
}
}
// ------------------------------------------------------------- accessors
public World world() {
return world;
}
public String name() {
return world.getName();
}
public String xaeroDim() {
return xaeroDim;
}
public Path regionDir() {
return regionDir;
}
public DimensionTypeInfo typeInfo() {
return typeInfo;
}
public List<RegionScanPass> passes() {
return passes;
}
public Path mcaPath(int regionX, int regionZ) {
return regionDir.resolve("r." + regionX + "." + regionZ + ".mca");
}
/** Wire key for a layer of a region, e.g. {@code minecraft$survival/caves/4/12_-3}. */
public String key(int caveLayer, int regionX, int regionZ) {
if (caveLayer == Integer.MAX_VALUE) {
return xaeroDim + "/" + regionX + "_" + regionZ;
}
return xaeroDim + "/caves/" + caveLayer + "/" + regionX + "_" + regionZ;
}
@Override
public String toString() {
return world.getName() + " (" + xaeroDim + ", " + passes.size() + " layer(s), " + regionDir + ")";
}
}
@@ -0,0 +1,79 @@
package lv.janhouse.mapsyncer.world;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import java.util.Locale;
/**
* Xaero's World Map folder naming for dimensions.
*
* <p>Xaero keeps one folder per dimension under {@code Multiplayer_<host>/}:
* the three vanilla keys get the historic names {@code null}, {@code DIM-1},
* {@code DIM1}; every other key is {@code namespace$path} (verified against a
* real client: a Multiverse world {@code survival} lands in
* {@code minecraft$survival}). The stock MapSyncer client mod computes a bare
* {@code survival} for the same key when it asks for a single dimension, so
* requests are matched leniently in {@link #matches}.
*/
public final class XaeroDimension {
private XaeroDimension() {}
/** The folder name to put on the wire for a world. */
public static String folderFor(World world) {
NamespacedKey key = world.getKey();
if (NamespacedKey.MINECRAFT.equals(key.getNamespace())) {
switch (key.getKey()) {
case "overworld":
return "null";
case "the_nether":
return "DIM-1";
case "the_end":
return "DIM1";
default:
break;
}
}
return key.getNamespace() + "$" + key.getKey();
}
/**
* Whether a dimension string sent by a client refers to {@code world}.
* Accepts Xaero's spelling ({@code minecraft$survival}), the mod's own
* ({@code survival}), a key ({@code minecraft:survival}) and the vanilla
* folder names.
*/
public static boolean matches(String requested, World world) {
if (requested == null || requested.isEmpty()) {
return false;
}
String folder = folderFor(world);
if (requested.equals(folder)) {
return true;
}
NamespacedKey key = world.getKey();
String r = requested.replace(':', '$');
if (r.equals(key.getNamespace() + "$" + key.getKey())) {
return true;
}
if (!r.contains("$")) {
String lower = r.toLowerCase(Locale.ROOT);
// Bare path, the way the stock client names a non-vanilla key.
if (lower.equals(key.getKey().toLowerCase(Locale.ROOT))) {
return true;
}
// Bare Bukkit world name, for people typing it by hand.
if (lower.equals(world.getName().toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
}
/** Dimension prefix of a wire key such as {@code minecraft$survival/12_-3}. */
public static String prefixOf(String key) {
int slash = key.indexOf('/');
return slash < 0 ? key : key.substring(0, slash);
}
}
+74
View File
@@ -0,0 +1,74 @@
# MapSyncer (Paper) — server half of the "MapSyncer for Xaero's World Map" mod.
#
# The server renders Xaero's World Map region files from the world on disk and
# streams them to players who run the MapSyncer client mod (Fabric/NeoForge,
# same Minecraft version as the server) alongside Xaero's World Map.
#
# Unlike the upstream mod, this plugin does NOT ship the whole generated world.
# It tracks which chunks were actually sent to players and renders only those,
# so pre-generated or never-visited terrain stays hidden until someone finds it.
# What a player receives.
# shared every player sees the union of everything anyone has discovered
# own each player sees only the chunks they discovered themselves
# (plus the seeded history below, which cannot be attributed)
visibility: shared
# On first start (or /mapsync seed) mark every chunk that players have
# already spent time in as discovered, using the chunk's InhabitedTime. Chunks
# that were merely pre-generated (Chunky, distant-terrain LOD generation, ...)
# have an InhabitedTime of 0 and stay hidden.
seed:
from-inhabited-time: true
# Minimum InhabitedTime (ticks) for a chunk to count. 1 = anything at all.
min-inhabited-ticks: 1
discovery:
# A chunk counts as discovered when the server sends it to a player, which is
# exactly what Xaero's client maps. -1 keeps that; a positive number caps the
# distance (in chunks) from the player at which a sent chunk still counts.
radius-chunks: -1
# How often the discovery bitmaps are flushed to disk (seconds).
save-interval-seconds: 60
render:
# How often dirty regions (changed world data or new discoveries) are
# re-rendered in the background (seconds).
interval-seconds: 300
# Worker threads for MCA -> Xaero conversion. Each region is a full parse of a
# ~10 MB region file; keep this small on a shared box.
threads: 2
# Flush the world to disk before a render cycle so freshly explored chunks are
# on disk. This is the same operation the autosave performs.
save-world-before-render: true
# A sync request whose regions are not rendered yet renders them inline, but
# stops after this many seconds and leaves the rest to the background cycle
# (the client re-polls). Keep well under the client's 10 minute session limit.
inline-budget-seconds: 420
sync:
# Largest sum of region bytes in one packet (client cap is 1,000,000).
max-packet-bytes: 262144
# Per-player send rate. 0 disables the limiter.
speed-limit-kbps: 1024
# Advertised to clients: how often (ticks) their mod re-polls for changes.
client-poll-interval-ticks: 6000
# Delay (ticks) between the client announcing the mod's channels and the
# server's hello. The client needs a few seconds after join to read its own
# cache, otherwise it forgets what it already has and re-syncs on every join.
hello-delay-ticks: 100
# Optional stable name sent to clients. The client mod uses it to recognise this
# server when it is reached under a different address. Leave empty to disable.
server-name: ""
# Per-world settings. Keys are Bukkit world names. Anything not listed uses
# "default". `layers` controls what is rendered:
# auto surface for worlds with a sky, cave layers for worlds with a ceiling
# surface top-down map only
# caves cave layers only (16 blocks each, like Xaero's own cave mode)
# both
worlds:
default:
enabled: true
layers: auto
+19
View File
@@ -0,0 +1,19 @@
name: MapSyncer
version: '${version}'
main: lv.janhouse.mapsyncer.MapSyncerPlugin
api-version: '1.21'
author: janhouse
description: Streams server-rendered Xaero's World Map regions to players running the MapSyncer client mod, limited to what players have actually discovered.
website: https://git.janhouse.lv/janhouse/mapsyncer-paper
load: POSTWORLD
commands:
mapsync:
description: Inspect and manage server-side map sync.
usage: /mapsync <status|worlds|render|seed|reload|player>
permission: mapsyncer.admin
permissions:
mapsyncer.admin:
description: Use /mapsync.
default: op
@@ -0,0 +1,83 @@
package lv.janhouse.mapsyncer.discovery;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class DiscoveryStoreTest {
@Test
void marksAreIdempotentAndStampsOnlyMoveOnGrowth() {
DiscoveryStore store = new DiscoveryStore(Path.of("unused"));
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
assertTrue(store.mark("w", a, 40, -3, 100));
assertFalse(store.mark("w", a, 40, -3, 200));
RegionDiscovery d = store.region("w", 1, -1, false);
assertEquals(1, d.unionCount());
assertEquals(100, d.unionStamp());
assertEquals(100, d.playerStamp(a));
assertEquals(0, d.playerStamp(b));
// b learns the same chunk: their stamp moves, the union's does not.
assertTrue(store.mark("w", b, 40, -3, 300));
assertEquals(100, d.unionStamp());
assertEquals(300, d.playerStamp(b));
assertEquals(1, d.unionCount());
// Masks: chunk 40,-3 is local (8, 29) in region (1,-1).
assertTrue(d.unionMask().includes(8, 29));
assertTrue(d.playerMask(a).includes(8, 29));
assertFalse(d.playerMask(a).includes(0, 0));
// Seeded chunks show up for everyone, including players with no data.
assertTrue(store.markSeeded("w", 33, -1, 400));
assertTrue(d.playerMask(UUID.randomUUID()).includes(1, 31));
assertEquals(400, d.unionStamp());
assertEquals(400, d.playerStamp(UUID.randomUUID()));
}
@Test
void roundTripsThroughDisk(@TempDir Path dir) {
DiscoveryStore store = new DiscoveryStore(dir);
UUID a = UUID.randomUUID();
store.mark("survival", a, -1, -1, 10);
store.mark("survival", a, 5000, 7, 20);
store.markSeeded("survival", 3, 3, 30);
List<String> warnings = new ArrayList<>();
assertEquals(List.of("survival"), store.saveDirty(warnings::add));
assertTrue(warnings.isEmpty());
assertTrue(store.hasFile("survival"));
DiscoveryStore loaded = new DiscoveryStore(dir);
loaded.load("survival", warnings::add);
assertTrue(warnings.isEmpty());
assertEquals(3, loaded.regionCount("survival"));
assertEquals(3, loaded.chunkCount("survival"));
assertEquals(2, loaded.playerChunkCount("survival", a));
RegionDiscovery r = loaded.region("survival", -1, -1, false);
assertTrue(r.unionMask().includes(31, 31));
assertEquals(10, r.playerStamp(a));
RegionDiscovery seeded = loaded.region("survival", 0, 0, false);
assertEquals(1, seeded.seededCount());
assertEquals(30, seeded.unionStamp());
}
@Test
void regionKeyPacksNegatives() {
long key = DiscoveryStore.regionKey(-7, 12);
assertEquals(-7, DiscoveryStore.regionX(key));
assertEquals(12, DiscoveryStore.regionZ(key));
key = DiscoveryStore.regionKey(3, -9);
assertEquals(3, DiscoveryStore.regionX(key));
assertEquals(-9, DiscoveryStore.regionZ(key));
}
}
@@ -0,0 +1,148 @@
package lv.janhouse.mapsyncer.net;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ProtocolTest {
@Test
void primitivesRoundTrip() {
byte[] bytes = new PacketWriter()
.writeInt(-2)
.writeLong(1_756_400_000L)
.writeBoolean(true)
.writeByte(0x81)
.writeVarInt(300)
.writeUtf("minecraft$survival/12_-3")
.writeByteArray(new byte[] {1, 2, 3})
.toByteArray();
PacketReader r = new PacketReader(bytes);
assertEquals(-2, r.readInt());
assertEquals(1_756_400_000L, r.readLong());
assertTrue(r.readBoolean());
assertEquals(0x81, r.readByte());
assertEquals(300, r.readVarInt());
assertEquals("minecraft$survival/12_-3", r.readUtf());
assertEquals(3, r.readVarInt());
assertEquals(0, r.readableBytes() - 3);
}
@Test
void varIntMatchesVanillaEncoding() {
// 300 = 0xAC 0x02 in Minecraft's VarInt encoding.
assertArrayEquals(new byte[] {(byte) 0xAC, 0x02}, new PacketWriter().writeVarInt(300).toByteArray());
// writeUtf = VarInt byte length + UTF-8, not Java's modified UTF.
assertArrayEquals(new byte[] {2, 'h', 'i'}, new PacketWriter().writeUtf("hi").toByteArray());
}
@Test
void decodesAFullRequest() {
byte[] payload = new PacketWriter()
.writeInt(2)
.writeUtf("null/0_0").writeLong(1000).writeUtf("a1b2c3d4")
.writeUtf("DIM-1/caves/4/1_1").writeLong(2000).writeUtf("00000000")
.writeBoolean(true).writeInt(1).writeInt(3)
.writeBoolean(false).writeUtf("minecraft$survival")
.writeBoolean(true)
.toByteArray();
Protocol.SyncRequest req = Protocol.decodeSyncRequest(payload);
assertEquals(2, req.clientMeta().size());
assertEquals(new Protocol.ClientMeta(1000, "a1b2c3d4"), req.clientMeta().get("null/0_0"));
assertTrue(req.split());
assertEquals(1, req.partIndex());
assertEquals(3, req.totalParts());
assertFalse(req.syncAll());
assertEquals("minecraft$survival", req.targetDimension());
assertTrue(req.silent());
}
@Test
void emptyLegacyRequestMeansSyncAll() {
Protocol.SyncRequest req = Protocol.decodeSyncRequest(new PacketWriter().writeInt(0).toByteArray());
assertTrue(req.syncAll());
assertEquals(Map.of(), req.clientMeta());
}
@Test
void truncatedRequestIsRejectedCleanly() {
byte[] payload = new PacketWriter().writeInt(1).writeUtf("null/0_0").toByteArray();
assertThrows(PacketReader.MalformedPacketException.class, () -> Protocol.decodeSyncRequest(payload));
}
@Test
void responseFramingWritesBothTrailingBooleansForEveryChunk() {
byte[] zip = "PKfake".getBytes(StandardCharsets.US_ASCII);
List<Protocol.ChunkPart> parts = List.of(
new Protocol.ChunkPart(1, -2, "DIM-1", zip, 5, 4, 0, 0),
new Protocol.ChunkPart(1, -2, "null", zip, 5, Protocol.SURFACE_LAYER, 0, 0));
byte[] packet = Protocol.encodeSyncResponse(7, parts, true, "ok");
PacketReader r = new PacketReader(packet);
assertEquals(7, r.readInt());
assertEquals(2, r.readInt());
// chunk 1: cave layer 4, unsplit
assertEquals(1, r.readInt());
assertEquals(-2, r.readInt());
assertEquals("DIM-1", r.readUtf());
assertEquals(zip.length, r.readVarInt());
for (int i = 0; i < zip.length; i++) {
r.readByte();
}
assertEquals(5, r.readLong());
assertTrue(r.readBoolean());
assertEquals(4, r.readInt());
assertFalse(r.readBoolean());
// chunk 2: surface, unsplit — the two booleans must still be present
assertEquals(1, r.readInt());
assertEquals(-2, r.readInt());
assertEquals("null", r.readUtf());
assertEquals(zip.length, r.readVarInt());
for (int i = 0; i < zip.length; i++) {
r.readByte();
}
assertEquals(5, r.readLong());
assertFalse(r.readBoolean());
assertFalse(r.readBoolean());
assertTrue(r.readBoolean());
assertEquals("ok", r.readUtf());
assertEquals(0, r.readableBytes());
}
@Test
void splitsLargeRegionsIntoContiguousParts() {
byte[] zip = new byte[Protocol.MAX_PART_BYTES * 2 + 10];
for (int i = 0; i < zip.length; i++) {
zip[i] = (byte) i;
}
List<Protocol.ChunkPart> parts = Protocol.split(3, 4, "null", zip, 9, Protocol.SURFACE_LAYER);
assertEquals(3, parts.size());
int offset = 0;
for (int i = 0; i < parts.size(); i++) {
Protocol.ChunkPart p = parts.get(i);
assertEquals(i, p.partIndex());
assertEquals(3, p.totalParts());
for (int j = 0; j < p.data().length; j++) {
assertEquals(zip[offset + j], p.data()[j]);
}
offset += p.data().length;
}
assertEquals(zip.length, offset);
assertFalse(Protocol.split(0, 0, "null", new byte[100], 1, Protocol.SURFACE_LAYER).get(0).isSplit());
}
@Test
void hashValidity() {
assertTrue(Protocol.isValidHash("a1b2c3d4"));
assertFalse(Protocol.isValidHash("00000000"));
assertFalse(Protocol.isValidHash(""));
assertFalse(Protocol.isValidHash(null));
}
}
@@ -0,0 +1,99 @@
package lv.janhouse.mapsyncer.render;
import com.mapsyncer.XaeroWriter;
import com.mapsyncer.mca.BlockPropertyLookup;
import com.mapsyncer.mca.ChunkMask;
import com.mapsyncer.mca.DimensionTypeInfo;
import com.mapsyncer.mca.RegionConverterStandalone;
import com.mapsyncer.mca.convert.RegionConversionPipeline;
import com.mapsyncer.mca.convert.scan.RegionScanPass;
import com.mapsyncer.util.HashUtils;
import lv.janhouse.mapsyncer.world.TrackedWorld;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* Converts a real region file when one is available (system property
* {@code mapsyncer.test.mca}); skipped otherwise, so CI does not need world
* data. Uses a crude name-based block lookup because the real one needs a
* running server — the point is exercising the MCA/NBT parser and the Xaero
* writer on current-format data, not colour fidelity.
*/
class RealRegionConversionTest {
/** Name-pattern stand-in for the NMS-backed resolver. */
static final BlockPropertyLookup NAME_LOOKUP = new BlockPropertyLookup() {
@Override public int getFlags(String n) {
int f = 0;
if (isWater(n)) f |= FLAG_WATER | FLAG_TRANSLUCENT_FLUID | FLAG_SHOULD_OVERLAY;
if (isTransparent(n)) f |= FLAG_TRANSPARENT | FLAG_SHOULD_OVERLAY;
if (isInvisible(n)) f |= FLAG_INVISIBLE;
if (isGlowing(n)) f |= FLAG_GLOWING;
if (isWaterInheriting(n)) f |= FLAG_WATER_INHERITING;
if (!n.endsWith("air")) f |= FLAG_HAS_VANILLA_COLOR;
return f;
}
@Override public boolean isWater(String n) { return n.equals("minecraft:water"); }
@Override public boolean isTransparent(String n) { return n.contains("glass") || n.equals("minecraft:ice"); }
@Override public boolean isInvisible(String n) { return n.contains("torch") || n.equals("minecraft:short_grass"); }
@Override public boolean shouldOverlay(String n) { return isWater(n) || isTransparent(n); }
@Override public boolean hasVanillaColor(String n) { return !n.endsWith("air"); }
@Override public boolean isGrassBlock(String n) { return n.equals("minecraft:grass_block"); }
@Override public boolean isGlowing(String n) { return n.contains("lava") || n.contains("glowstone"); }
@Override public boolean isTranslucentFluid(String n) { return isWater(n); }
@Override public boolean isWaterloggedSurface(String n, Map<String, String> p) {
return p != null && "true".equals(p.get("waterlogged")) && !isWater(n);
}
@Override public boolean isWaterInheriting(String n) { return n.contains("seagrass") || n.contains("kelp"); }
@Override public int getLightBlock(String n) { return n.endsWith("air") ? 0 : isWater(n) ? 1 : 15; }
};
@Test
void convertsARealOverworldRegion(@TempDir Path out) throws Exception {
String prop = System.getProperty("mapsyncer.test.mca");
assumeTrue(prop != null && Files.exists(Path.of(prop)), "no real region file configured");
Path mca = Path.of(prop);
DimensionTypeInfo info = DimensionTypeInfo.overworld();
List<RegionScanPass> passes = TrackedWorld.plan(TrackedWorld.Layers.SURFACE, info);
assertEquals(1, passes.size());
// Full region.
List<RegionConverterStandalone.LayerConvertedRegion> full =
RegionConversionPipeline.convertMulti(mca, 0, 0, info, passes, NAME_LOOKUP, ChunkMask.ALL);
assertEquals(1, full.size());
assertTrue(full.get(0).xaeroData().length > 1000, "surface layer should have content");
// Masked to one chunk: still a valid region, but much smaller.
ChunkMask oneChunk = (x, z) -> x == 0 && z == 0;
List<RegionConverterStandalone.LayerConvertedRegion> masked =
RegionConversionPipeline.convertMulti(mca, 0, 0, info, passes, NAME_LOOKUP, oneChunk);
assertEquals(1, masked.size());
assertTrue(masked.get(0).xaeroData().length < full.get(0).xaeroData().length / 4,
"masked region should be far smaller than the full one");
// Nothing selected: no data at all.
List<RegionConverterStandalone.LayerConvertedRegion> none =
RegionConversionPipeline.convertMulti(mca, 0, 0, info, passes, NAME_LOOKUP, (x, z) -> false);
assertEquals(0, none.get(0).xaeroData().length);
// The zip the client will receive validates the way the client validates it.
XaeroWriter.RegionWriteResult written = XaeroWriter.writeRegionFile(out,
new RegionConverterStandalone.ConvertedRegion(0, 0, full.get(0).xaeroData()));
byte[] zip = Files.readAllBytes(written.path());
assertTrue(HashUtils.isValidRegionZip(zip));
assertEquals(HashUtils.computeHash(zip), written.crc32Hash());
assertFalse(written.crc32Hash().equals(HashUtils.DEFAULT_HASH));
System.out.println("full=" + full.get(0).xaeroData().length + " bytes, masked="
+ masked.get(0).xaeroData().length + " bytes, zip=" + zip.length + " bytes");
}
}