1
0
Fork 0

Client size edits

windows
May B. 2020-11-07 22:16:48 +01:00
parent 2bb02bef1f
commit fa482b37f3
14 changed files with 256 additions and 96 deletions

16
TODO.md
View File

@ -20,30 +20,30 @@
## Hello world
- [~] Map stream
- [ ] Modifications compression
- [ ] Local prediction
- [ ] Contouring service
- [~] Edit
- [ ] Compression (raw)
- [x] Shape iterators
- [ ] Anchor
- [x] Prevent suffocation
- [ ] Local prediction
- [x] Local prediction
- [~] Occlusion Culling
- [ ] Iterator ray
- [~] Iterator ray
- [ ] Cast from chunk center
- [x] Transparency
- [~] Entities
- [ ] Collide
- [ ] Get models
- [ ] Reduce compile unit count
- [ ] Review documentation
- [~] Review documentation
- [ ] Clean kick
## Hello universe
- [ ] CI build
- CMake package
- GitLab / Drone pipeline
- [~] CI build
- [ ] CMake package
- [x] GitLab CI
- [ ] Universe
- [ ] Galaxy
- [ ] Rotation
@ -79,7 +79,7 @@
- [ ] Slash screen
- [ ] Start/Pause menu
- [x] QUIC protocol
- [ ] 8 for 1 contouring problem
- [~] 8 for 1 contouring problem
- [ ] Use in memory protocol (to replace server_handle)
- [ ] Octree
- [ ] Better Lod

View File

@ -66,6 +66,8 @@ public:
world.keepDistance = config["world"]["keep_distance"].value_or(world.keepDistance);
world.useAverages = config["world"]["use_averages"].value_or(world.useAverages);
world.trustMajorant = config["world"]["trust_majorant"].value_or(world.trustMajorant);
world.editPrediction = config["world"]["edit_prediction"].value_or(world.editPrediction);
world.editHandling = config["world"]["edit_handling"].value_or(world.editHandling);
voxel_density = config["world"]["voxel_density"].value_or(voxel_density);
#ifndef STANDALONE
@ -144,6 +146,8 @@ public:
{"keep_distance", world.keepDistance},
{"use_averages", world.useAverages},
{"trust_majorant", world.trustMajorant},
{"edit_prediction", world.editPrediction},
{"edit_handling", world.editHandling},
{"voxel_density", voxel_density}
}));
if(connection.has_value()) {

View File

@ -4,12 +4,48 @@
namespace world::client {
class Chunk final: public EdittableChunk {
public:
Chunk(): world::Chunk(), EdittableChunk() { }
};
class Chunk final: public EdittableChunk {
public:
Chunk(std::istream &is): world::Chunk(is) { }
/// Create from average
Chunk(Voxel val): EdittableChunk(), isAverage(true), isMajorant(val.swap()) { voxels.fill(Voxel(val.material(), val.density())); }
Chunk(): world::Chunk(), EdittableChunk() { }
/// Chunk full of air
static const std::shared_ptr<const Chunk> EMPTY_CHUNK = std::make_shared<Chunk>();
std::optional<Faces> update(float deltaTime, bool animate) override {
for (auto it = futureEdits.begin(); it != futureEdits.end();) {
it->second -= deltaTime;
if (it->second <= 0 && animate) {
invalidate(it->first.idx);
edits.emplace_back(it->first);
it = futureEdits.erase(it);
} else {
it++;
}
}
return EdittableChunk::update(deltaTime, animate);
}
constexpr bool isTrusted(bool allowMajorant) const { return !isAverage || (allowMajorant && isMajorant); }
void unsetMajorant() {
assert(isMajorant);
isMajorant = false;
}
/// Add future edit
void addFutureEdit(Chunk::Edit edit, float delay) { futureEdits.emplace_back(edit, delay); }
protected:
/// Is temporary average
const bool isAverage = false;
/// Is temporary full valued
bool isMajorant = false;
/// Temporary animated changes
/// MAYBE: sort by delay
std::vector<std::pair<Chunk::Edit, float>> futureEdits;
};
/// Chunk full of air
static const std::shared_ptr<const Chunk> EMPTY_CHUNK = std::make_shared<Chunk>();
}

View File

@ -5,6 +5,7 @@
#include "Area.hpp"
#include "../contouring/Abstract.hpp"
#include "../../core/world/raycast.hpp"
#include "../../core/world/iterators.hpp"
#include "../../core/net/io.hpp"
#include "../../core/utils/logger.hpp"
#include "Chunk.hpp"
@ -40,7 +41,7 @@ void DistantUniverse::update(voxel_pos pos, float deltaTime) {
if (glm::length2(diff - it_c->first) > glm::pow2(options.keepDistance)) {
it_c = chunks.erase(it_c);
} else {
if(const auto neighbors = std::dynamic_pointer_cast<world::client::EdittableChunk>(it_c->second)->update(deltaTime, true /*MAYBE: random update*/)) {
if(const auto neighbors = std::dynamic_pointer_cast<Chunk>(it_c->second)->update(deltaTime, true /*MAYBE: random update*/)) {
contouring->onUpdate(std::make_pair(area.first, it_c->first), diff, chunks, neighbors.value());
}
++it_c;
@ -63,7 +64,7 @@ void DistantUniverse::update(voxel_pos pos, float deltaTime) {
const auto p = diff + chunk_pos(x, y, z);
if (dist2 <= queryDistance * queryDistance && chunks.inRange(p)) {
if (auto it = chunks.find(p); it != chunks.end() &&
std::dynamic_pointer_cast<world::client::EdittableChunk>(it->second)->isTrusted(options.trustMajorant))
std::dynamic_pointer_cast<Chunk>(it->second)->isTrusted(options.trustMajorant))
//TODO: config accept perfect average
{
contouring->onNotify(std::make_pair(area.first, p), diff, chunks);
@ -73,7 +74,7 @@ void DistantUniverse::update(voxel_pos pos, float deltaTime) {
const auto rcPos = glm::split(p);
if(auto it_r = cl_area->regionCache.find(rcPos.first); it_r != cl_area->regionCache.end()) {
if (auto it_rc = it_r->second.find(rcPos.second); it_rc != it_r->second.end() && (it_rc->second.swap() || options.useAverages)) {
auto ck = std::make_shared<world::client::EdittableChunk>(it_rc->second);
auto ck = std::make_shared<Chunk>(it_rc->second);
ck->invalidate(geometry::Faces::All);
chunks.emplace(p, std::dynamic_pointer_cast<world::Chunk>(ck));
contouring->onNotify(std::make_pair(area.first, p), diff, chunks);
@ -111,7 +112,6 @@ void DistantUniverse::update(voxel_pos pos, float deltaTime) {
packet.write(area.first);
packet.write(missingChunks.data(), missingChunks.size() * sizeof(chunk_pos));
peer.send(packet.finish());
LOG("Query " << missingChunks.size());
}
if(!missingRegions.empty()) {
auto packet = net::PacketWriter(net::client_packet_type::MISSING_REGIONS, sizeof(area_id) + missingRegions.size() * sizeof(region_pos));
@ -151,8 +151,19 @@ bool DistantUniverse::onPacket(const data::out_view& buf, net::PacketFlags) {
break;
}
case server_packet_type::CAPABILITIES:
return packet.read(serverDistance);
case server_packet_type::CAPABILITIES: {
packet.read(serverDistance);
auto predictable = false;
packet.read(predictable);
if (!predictable) {
options.editHandling = false;
options.editPrediction = false;
}
auto packet = PacketWriter(client_packet_type::CAPABILITIES, sizeof(bool));
packet.write(options.editHandling);
peer.send(packet.finish());
break;
}
case server_packet_type::COMPRESSION: {
const auto remain = packet.readAll();
@ -222,7 +233,6 @@ bool DistantUniverse::onPacket(const data::out_view& buf, net::PacketFlags) {
if (voxel.swap())
full++;
}
LOG(full << "/" << total);
break;
}
@ -257,7 +267,7 @@ bool DistantUniverse::onPacket(const data::out_view& buf, net::PacketFlags) {
}
data::vec_istream idata(buffer);
std::istream iss(&idata);
auto ck = std::make_shared<world::client::EdittableChunk>(iss);
auto ck = std::make_shared<Chunk>(iss);
ck->invalidate(geometry::Faces::All);
auto ptr = std::dynamic_pointer_cast<world::Chunk>(ck);
auto &chunks = it->second->setChunks();
@ -271,6 +281,38 @@ bool DistantUniverse::onPacket(const data::out_view& buf, net::PacketFlags) {
case server_packet_type::EDITS: {
ZoneScopedN("Edits");
if (!options.editHandling) {
LOG_W("Unhandled edit type");
break;
}
const auto fill = packet.read<action::FillShape>();
if (!fill)
break;
if(const auto it = areas.find(fill->pos.first); it != areas.end()) {
auto &chunks = it->second->setChunks();
auto iterator = world::iterator::Get(fill->shape, fill->radius);
world::iterator::pair point;
while (iterator->next(point)) {
const voxel_pos offset = point.first;
const auto split = glm::splitIdx(fill->pos.second + offset);
if(chunks.inRange(split.first)) {
if(const auto chunk = it->second->setChunks().findInRange(split.first)) {
auto ck = std::dynamic_pointer_cast<Chunk>(chunk.value());
auto prev = ck->get(split.second);
const auto next = prev.filled(fill->val, point.second);
const auto delay = glm::length2(offset) / fill->radius * .05f;
ck->apply(Chunk::Edit{split.second, next, delay});
}
}
}
}
break;
}
case server_packet_type::RAW_EDITS: {
ZoneScopedN("Raw Edits");
area_id id;
if(!packet.read(id))
break;
@ -287,7 +329,7 @@ bool DistantUniverse::onPacket(const data::out_view& buf, net::PacketFlags) {
chunk_voxel_idx count = 0;
packet.read(count);
if (auto ptr = it->second->setChunks().findInRange(pos)) {
auto chunk = std::dynamic_pointer_cast<world::client::EdittableChunk>(ptr.value());
auto chunk = std::dynamic_pointer_cast<Chunk>(ptr.value());
for (auto i = 0; i < count && !packet.isFull(); i++) {
Chunk::Edit edit;
packet.read(edit);
@ -351,6 +393,30 @@ void DistantUniverse::emit(const action::packet &action) {
peer.send(net::PacketWriter::Of(net::client_packet_type::MESSAGE, message->text.data(), message->text.size()));
} else if(const auto fill = std::get_if<action::FillShape>(&action)) {
peer.send(net::PacketWriter::Of(net::client_packet_type::FILL_SHAPE, *fill));
if (options.editPrediction) {
ZoneScopedN("Fill");
const auto keepDelay = 10 + (peer.getRTT() / 20000.f); // 10s + 50RTT
if(const auto it = areas.find(fill->pos.first); it != areas.end()) {
auto &chunks = it->second->setChunks();
auto iterator = world::iterator::Get(fill->shape, fill->radius);
world::iterator::pair point;
while (iterator->next(point)) {
const voxel_pos offset = point.first;
const auto split = glm::splitIdx(fill->pos.second + offset);
if(chunks.inRange(split.first)) {
if(const auto chunk = it->second->setChunks().findInRange(split.first)) {
auto ck = std::dynamic_pointer_cast<Chunk>(chunk.value());
auto prev = ck->get(split.second);
const auto next = prev.filled(fill->val, point.second);
if(prev.value != next.value) {
const auto delay = glm::length2(offset) / fill->radius * .05f;
ck->addFutureEdit(Chunk::Edit{split.second, next, keepDelay - delay * 2}, delay);
}
}
}
}
}
}
} else {
LOG_W("Bad action " << action.index());
}

View File

@ -25,6 +25,10 @@ namespace world::client {
/// Dont query chunks with absolute majorant average
/// Avoid querying whole space but may cause missed edits in those chunks
bool trustMajorant = true;
/// Apply edits locally while sending
bool editPrediction = true;
/// Get only edits commands and compute locally
bool editHandling = true;
};
struct connection: net::address {
connection(): net::address{"localhost", 4242} { }

View File

@ -321,6 +321,9 @@ void Connection::release(uint16_t reason) {
}
}
uint64_t Connection::getRTT() {
return cnx != nullptr ? picoquic_get_rtt(cnx) : INT64_MAX;
}
uint16_t Connection::getErrorCode(bool is_app) {
return cnx != nullptr ? (is_app ? cnx->remote_application_error : cnx->remote_error) : 42;
}

View File

@ -103,7 +103,10 @@ public:
void setCallback(picoquic_stream_data_cb_fn cb_fn, void *ctx);
/// Average roundtrip time in microseconds
uint64_t getRTT();
uint16_t getErrorCode(bool is_app);
/// Ip address and port
std::string getAddress();
/// Send reliable data

View File

@ -45,30 +45,33 @@ enum class server_packet_type: uint8_t {
/// {area_<chunk_pos>, zstd<chunk rle>}
/// empty: all sent
CHUNK = 18,
/// Chunk changes
/// {area_id, {chunk_pos, ushort(count), Chunk::Edit[]}[]} notify
/// MAYBE: compress
EDITS = 19,
/// Uncompressed chunk changes
/// {area_id, {chunk_pos, ushort(count), Chunk::Edit[]}[]}
RAW_EDITS = 19,
/// Chunk changes instruction
/// action::FillShape
EDITS = 20,
/// Declare entities types
/// {size_t(index), vec3(size), vec3(scale)}
/// TODO: zstd<chunk rle>
ENTITY_TYPES = 20,
ENTITY_TYPES = 32,
/// Update entities instances position and velocity
/// {size_t(entity), size_t(count), {size_t(index), ifvec3(pos), vec3(velocity)}[]}[]
ENTITIES = 21,
ENTITIES = 33,
/// World compression dictionary
/// zstd dict
COMPRESSION = 24,
COMPRESSION = 64,
/// Server capabilities
/// ushort(loadDistance), MAYBE: more
CAPABILITIES = 25,
/// ushort(loadDistance), bool(predictable)
//MAYBE: use uint8_t flags
CAPABILITIES = 65,
/// Public chat message
/// char[] (not null terminated)
MESSAGE = 29,
MESSAGE = 129,
};
/// Packets from client to server
enum class client_packet_type: uint8_t {
@ -91,6 +94,11 @@ enum class client_packet_type: uint8_t {
/// Position update (unreliable)
/// (sequence)uint64_t voxel_pos
MOVE = 16,
/// Client capabilities
/// bool(edit_handling)
//MAYBE: use uint8_t flags
CAPABILITIES = 65,
};
constexpr auto MAX_PENDING_CHUNK_COUNT = 256;

View File

@ -7,8 +7,6 @@
using namespace world::client;
EdittableChunk::EdittableChunk(): world::Chunk() { }
EdittableChunk::EdittableChunk(std::istream &is): world::Chunk(is) { }
EdittableChunk::EdittableChunk(Voxel val): world::Chunk(), isAverage(true), isMajorant(val.swap()) { voxels.fill(Voxel(val.material(), val.density())); }
EdittableChunk::~EdittableChunk() { }
std::optional<Faces> EdittableChunk::update(float deltaTime, bool animate) {

View File

@ -7,9 +7,6 @@ using namespace geometry;
namespace world::client {
class EdittableChunk: public virtual world::Chunk {
public:
EdittableChunk(std::istream &is);
/// Create from average
EdittableChunk(Voxel val);
virtual ~EdittableChunk();
/// Update voxels
@ -30,21 +27,11 @@ namespace world::client {
static std::optional<chunk_voxel_idx> getNeighborIdx(chunk_voxel_idx idx, Face dir);
constexpr bool isTrusted(bool allowMajorant) const { return !isAverage || (allowMajorant && isMajorant); }
void unsetMajorant() {
assert(isMajorant);
isMajorant = false;
}
protected:
EdittableChunk();
/// Is temporary average
const bool isAverage = false;
/// Is temporary full valued
bool isMajorant = false;
/// Temporary changes
/// Animated changes
/// MAYBE: sort by delay
std::vector<Chunk::Edit> edits;
/// Require update
bool upToDate = true;

View File

@ -68,6 +68,21 @@ namespace world {
constexpr inline bool is_full() const {
return density() == DENSITY_MAX && !materials::transparency[material()];
}
/// Value after fill operation
inline Voxel const filled(Voxel in, float ratio) {
if (ratio >= 1)
return in;
const world::Voxel::density_t dst = in.density() * ratio;
if (!in.is_material())
return world::Voxel(material(), world::Voxel::DENSITY_MAX-dst, swap());
if (is_material() && density() > dst)
return Voxel(value);
return world::Voxel(in.material(), dst, in.swap());
}
};
/// Stock of material
struct Item {

View File

@ -16,6 +16,8 @@ namespace net::server {
picoquic_set_callback(cnx, connection_callback, peer);
}
assert(peer == nullptr || peer->contains(cnx) || fin_or_event == picoquic_callback_close);
} else if (peer == (void*)server) {
peer = nullptr;
}
assert(v_stream_ctx == nullptr || ((net::stream_ctx*)v_stream_ctx)->stream_id == stream_id);

View File

@ -10,6 +10,7 @@ enum queue: uint8_t {
GLOBAL = 0,
ENTITY,
CHUNK,
EDIT,
count
};

View File

@ -13,8 +13,9 @@
using namespace world::server;
const auto AREAS_FILE = "/areas.idx";
const auto COMPRESSION_PREFIX = (uint8_t)net::server_packet_type::COMPRESSION;
constexpr auto AREAS_FILE = "/areas.idx";
constexpr auto COMPRESSION_PREFIX = (uint8_t)net::server_packet_type::COMPRESSION;
constexpr auto PREDICTABLE = true;
Universe::Universe(const Universe::options &options): host(options.connection,
[&](net::server::Peer* peer) { return onConnect(peer); },
@ -164,6 +165,10 @@ struct net_client {
}
pendingChunks.insert(it, std::make_pair(in, dist));
}
bool handleEdits = false;
// MAYBE: client size ordering
std::queue<world::action::FillShape> pendingEdits;
};
void Universe::saveAll(bool remove) {
@ -223,23 +228,30 @@ void Universe::pull() {
host.pull();
host.iterPeers([&](net::server::Peer *peer) {
auto data = peer->getCtx<net_client>();
if (data == nullptr || data->pendingChunks.empty())
if (data == nullptr)
return;
//TODO: use congestion
area_<chunk_pos> pending;
size_t i = peer->queueSize(net::server::queue::CHUNK);
while(i <= 4 && data->popPendingChunk(pending)) {
if (const auto it = areas.find(pending.first); it != areas.end()) {
if (const auto chunk = it->second->getChunks().findInRange(pending.second)) {
peer->send(serializeChunk(pending, std::dynamic_pointer_cast<Chunk>(chunk.value())), net::server::queue::CHUNK);
i++;
}
}
if (data->pendingEdits.empty() && peer->queueSize(net::server::queue::EDIT) == 0) {
peer->send(net::PacketWriter::Of(net::server_packet_type::EDITS, data->pendingEdits.front()));
data->pendingEdits.pop();
}
if (data->pendingChunks.empty())
peer->send(net::PacketWriter(net::server_packet_type::CHUNK, 0).finish()); //FIXME: must be received after last chunk
if (!data->pendingChunks.empty()) {
//TODO: use congestion
area_<chunk_pos> pending;
size_t i = peer->queueSize(net::server::queue::CHUNK);
while(i <= 4 && data->popPendingChunk(pending)) {
if (const auto it = areas.find(pending.first); it != areas.end()) {
if (const auto chunk = it->second->getChunks().findInRange(pending.second)) {
peer->send(serializeChunk(pending, std::dynamic_pointer_cast<Chunk>(chunk.value())), net::server::queue::CHUNK);
i++;
}
}
}
if (data->pendingChunks.empty())
peer->send(net::PacketWriter(net::server_packet_type::CHUNK, 0).finish()); //FIXME: must be received after last chunk
}
});
}
@ -451,17 +463,23 @@ void Universe::update(float deltaTime) {
std::optional<uint16_t> Universe::onConnect(net::server::Peer* peer) {
ZoneScopedN("Connect");
using namespace net;
LOG_I("Client connect from " << peer->getAddress());
net_client* client = new net_client(entities.at(PLAYER_ENTITY_ID).instances.emplace(Entity::Instance{spawnPoint, glm::vec3(0)}));
peer->ctx = client;
peer->send(net::PacketWriter::Of(net::server_packet_type::CAPABILITIES, loadDistance));
{
auto packet = PacketWriter(server_packet_type::CAPABILITIES, sizeof(loadDistance) + sizeof(bool));
packet.write(loadDistance);
packet.write(PREDICTABLE);
peer->send(packet.finish());
}
//TODO: lock while not received
peer->send(data::out_buffer(data::out_view((uint8_t*)dict_content.data(), dict_content.size()), nullptr), net::server::queue::CHUNK);
{
auto player = findEntity(PLAYER_ENTITY_ID, client->instanceId);
auto packet = net::PacketWriter(net::server_packet_type::TELEPORT, sizeof(size_t) + sizeof(voxel_pos));
auto packet = PacketWriter(server_packet_type::TELEPORT, sizeof(size_t) + sizeof(voxel_pos));
packet.write(client->instanceId.index);
packet.write(player->pos.as_voxel());
peer->send(packet.finish());
@ -469,7 +487,7 @@ std::optional<uint16_t> Universe::onConnect(net::server::Peer* peer) {
}
{
constexpr auto ITEM_SIZE = sizeof(entity_id::index) + sizeof(glm::vec3) * 2;
auto packet = net::PacketWriter(net::server_packet_type::ENTITY_TYPES, ITEM_SIZE * entities.size());
auto packet = PacketWriter(server_packet_type::ENTITY_TYPES, ITEM_SIZE * entities.size());
entities.iter([&](entity_id id, const Entity &entity) {
packet.write(id.index);
packet.write(entity.size);
@ -507,6 +525,14 @@ bool Universe::onPacket(net::server::Peer *peer, const data::out_view &buf, net:
}
switch (type) {
case client_packet_type::CAPABILITIES: {
auto data = peer->getCtx<net_client>();
packet.read(data->handleEdits);
if (!PREDICTABLE && data->handleEdits) {
LOG_E("Client misread capabilities");
}
break;
}
case client_packet_type::MOVE: {
auto data = peer->getCtx<net_client>();
if (voxel_pos pos; !packet.read(pos) ||
@ -522,6 +548,12 @@ bool Universe::onPacket(net::server::Peer *peer, const data::out_view &buf, net:
LOG_T("Entity in solid fill area");
break;
}
host.iterPeers([&](net::server::Peer *peer) {
auto data = peer->getCtx<net_client>();
//MAYBE: only in range
if (data && data->handleEdits)
data->pendingEdits.push(*fill);
});
set(fill->pos, fill->radius, fill->shape, fill->val);
//TODO: handle inventory
} else {
@ -672,19 +704,7 @@ world::ItemList Universe::set(const area_<voxel_pos>& pos, int radius, action::S
if(const auto chunk = it->second->setChunks().findInRange(split.first)) {
auto ck = std::dynamic_pointer_cast<Chunk>(chunk.value());
auto prev = ck->get(split.second);
const auto next = [&] {
if (point.second == 1)
return val;
const world::Voxel::density_t density = val.density() * point.second;
if (!val.is_material())
return world::Voxel(prev.material(), world::Voxel::DENSITY_MAX-density);
if (prev.is_material() && prev.density() > density)
return prev;
return world::Voxel(val.material(), density);
}();
const auto next = prev.filled(val, point.second);
if(prev.value != next.value) {
//TODO: apply break table
//TODO: inventory
@ -696,22 +716,35 @@ world::ItemList Universe::set(const area_<voxel_pos>& pos, int radius, action::S
}
}
ZoneScopedN("Packet");
size_t size = sizeof(area_id);
for(const auto& part: edits) {
size += sizeof(chunk_pos);
size += sizeof(chunk_voxel_idx);
size += sizeof(Chunk::Edit) * part.second.size();
bool stupidClient = false;
host.iterPeers([&](net::server::Peer *peer) {
auto data = peer->getCtx<net_client>();
if (data && !data->handleEdits)
stupidClient = true;
});
if (stupidClient) {
ZoneScopedN("Packet");
size_t size = sizeof(area_id);
for(const auto& part: edits) {
size += sizeof(chunk_pos);
size += sizeof(chunk_voxel_idx);
size += sizeof(Chunk::Edit) * part.second.size();
}
auto packet = net::PacketWriter(net::server_packet_type::EDITS, size);
packet.write(pos.first);
for(const auto& part: edits) {
packet.write(part.first);
packet.write<chunk_voxel_idx>(part.second.size());
packet.write(part.second.data(), part.second.size() * sizeof(Chunk::Edit));
}
auto buffer = packet.finish();
host.iterPeers([&](net::server::Peer *peer) {
//MAYBE: only in range
auto data = peer->getCtx<net_client>();
if (data && !data->handleEdits)
peer->send(buffer, net::server::queue::CHUNK);
});
}
auto packet = net::PacketWriter(net::server_packet_type::EDITS, size);
packet.write(pos.first);
for(const auto& part: edits) {
packet.write(part.first);
packet.write<chunk_voxel_idx>(part.second.size());
packet.write(part.second.data(), part.second.size() * sizeof(Chunk::Edit));
}
//MAYBE: only in range
host.sendBroadcast(packet.finish(), net::server::queue::CHUNK);
}
return list;
}